/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Lazy extraction of IfcAnnotation 2D curves for the section-plane overlay. * * The WASM `parseSymbolicRepresentations` already emits polylines and arcs in * the same 2D coordinate space the Section2DPanel feeds to * `Section2DOverlayRenderer`. We only ever need the data when the IFC * Annotation toggle is on AND a section plane is active, so the parse runs * lazily and is cached per model source. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { GeometryProcessor } from '@ifc-lite/geometry'; import type { DrawingLine2D } from '@ifc-lite/renderer'; import { decodeIfcString } from '@ifc-lite/encoding'; import { useViewerStore } from '@/store'; import { useShallow } from 'zustand/react/shallow'; import type { IfcDataStore } from '@ifc-lite/parser'; import { hasEntityType } from './has-entity-type.js'; /** Lines belonging to a single storey, ready to feed into the section overlay. */ export interface AnnotationsForStorey { storeyId: number; /** Authored `IfcBuildingStorey.Elevation`. `null` means the storey carried * no elevation in the parsed metadata — distinguishing that from a real * ground-floor at 0.0 matters because `resolveBucketY` only wants to swap * in the fallback in the missing case, not for legitimate ground floors. */ storeyElevation: number | null; lines: DrawingLine2D[]; texts: AnnotationText2D[]; fills: AnnotationFill2D[]; } /** * A single text label in renderer 2D space (XZ on the section plane). * * `dirX / dirY` encodes the baseline direction (already mirrored to match the * Y-negated 2D coord system that lines and circles use). `height` is in world * units. `alignment` is the raw IFC `BoxAlignment` string ("bottom-left", * "center", …) — the renderer interprets it. */ export interface AnnotationText2D { x: number; y: number; dirX: number; dirY: number; height: number; content: string; alignment: string; /** * For multi-line text literals (e.g. CJK descriptions with `\X\0A` * newlines), one IfcTextLiteralWithExtent expands into one AnnotationText2D * per line. `lineYOffset` is added to the storey-elevation world-Y at 3D * conversion so successive lines stack downward (negative Y) below the * shared anchor. Optional — single-line literals leave it undefined. */ lineYOffset?: number; /** * When true, the renderer rebuilds the glyph quad in screen-aligned * (cameraRight, cameraUp) basis so the text always faces the camera. * Set for every annotation literal (grid bubbles, dimension callouts, * leader labels) so they stay legible in any view — flat-in-plane text * collapses to a sliver at oblique angles (issue #812). Defaults to false. */ billboard?: boolean; /** sRGB straight-alpha tint (0..1). Defaults to renderer near-black. */ color?: [number, number, number, number]; /** Per-instance target cap height in screen pixels. 0/undef = renderer default. */ targetPx?: number; } /** * A single filled region in renderer 2D space. Outer ring + holes flattened * into one `points` array; `holesOffsets` marks where each hole starts (in * vertex indices, not floats). Empty `holesOffsets` = simple polygon. * * `hatching` is present when the IFC style chain resolved to an * IfcFillAreaStyleHatching. When absent the fill is solid (color only). */ export interface AnnotationFill2D { points: Float32Array; holesOffsets: Uint32Array; color: [number, number, number, number]; hatching?: { spacing: number; angle: number; angleSecondary: number | null; lineWidth: number; }; } /** Cached parse result keyed by source identity. * * IfcAnnotation and IfcGridAxis primitives are stored in PARALLEL bucket * collections (issue #862). They share the same parse pass and the same * storey-resolution logic, but the renderer treats them differently: * * - Annotation buckets always lift every storey (memory * `feedback_3d_annotation_overlay_no_section_filter.md`: the user * expects every storey's dimensions to be visible in 3D). * - Grid buckets get optional section-plane filtering and an * independent visibility toggle, so dense-grid models can hide * grids per storey without losing dimensions. */ interface ParseResult { // IfcAnnotation buckets byStorey: Map; loose: DrawingLine2D[]; looseTexts: AnnotationText2D[]; looseFills: AnnotationFill2D[]; // IfcGridAxis buckets (issue #862) gridByStorey: Map; gridLoose: DrawingLine2D[]; gridLooseTexts: AnnotationText2D[]; gridLooseFills: AnnotationFill2D[]; } const CIRCLE_SEGMENTS_FULL = 32; const CIRCLE_SEGMENTS_ARC = 16; /** * Convert a polyline (Float32Array of [x,y,x,y,…]) into start/end segments. * Exported for unit testing. */ export function polylineToSegments( points: Float32Array, pointCount: number, isClosed: boolean, out: DrawingLine2D[], ): void { for (let j = 0; j < pointCount - 1; j++) { out.push({ line: { start: { x: points[j * 2], y: points[j * 2 + 1] }, end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] }, }, category: 'annotation', }); } if (isClosed && pointCount > 2) { out.push({ line: { start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] }, end: { x: points[0], y: points[1] }, }, category: 'annotation', }); } } /** * Tessellate a circle/arc into chord segments. * Exported for unit testing. */ export function circleToSegments( centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, isFullCircle: boolean, out: DrawingLine2D[], ): void { const numSegments = isFullCircle ? CIRCLE_SEGMENTS_FULL : CIRCLE_SEGMENTS_ARC; for (let j = 0; j < numSegments; j++) { const t1 = j / numSegments; const t2 = (j + 1) / numSegments; const a1 = startAngle + t1 * (endAngle - startAngle); const a2 = startAngle + t2 * (endAngle - startAngle); out.push({ line: { start: { x: centerX + radius * Math.cos(a1), y: centerY + radius * Math.sin(a1) }, end: { x: centerX + radius * Math.cos(a2), y: centerY + radius * Math.sin(a2) }, }, category: 'annotation', }); } } /** Make a stable cache key for one parsed source. * * Uses byteLength + a sample of the actual bytes (head, middle, tail) so two * different IFC sources can't alias even when they happen to share an exact * size — a real risk in federated views with multiple loaded models, and the * symptom is that the second model's annotations get hidden because the parse * effect skips it as "already cached". Sampling 96 bytes is cheap, doesn't * read the whole file, and is collision-resistant in practice. The buffer * identity is also folded in so the same content loaded twice from two * different ArrayBuffers (rare but possible) keeps distinct entries. */ function sourceKey(store: IfcDataStore | null | undefined): string | null { const source = store?.source; if (!source || source.byteLength === 0) return null; const len = source.byteLength; const sampleLen = Math.min(32, len); const head = source.subarray(0, sampleLen); const tail = source.subarray(len - sampleLen, len); const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2)); const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len)); // Fold each window into a 32-bit FNV-1a; cheap and collision-resistant for // 96 bytes of structurally distinct IFC headers/body/footer. const hashOne = (bytes: Uint8Array): string => { let h = 0x811c9dc5; for (let i = 0; i < bytes.length; i++) { h ^= bytes[i]; h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16); }; return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`; } /** Set `localStorage.IFC_ANNOTATIONS_DEBUG = '1'` in the browser to log * per-store parse counts + lift vertex counts to the console. Off by * default; useful when triaging "no annotations visible" reports. */ const debugEnabled = (): boolean => { if (typeof window === 'undefined') return false; try { return window.localStorage?.getItem('IFC_ANNOTATIONS_DEBUG') === '1'; } catch { return false; } }; async function parseAnnotations( store: IfcDataStore, ): Promise { const result: ParseResult = { byStorey: new Map(), loose: [], looseTexts: [], looseFills: [], gridByStorey: new Map(), gridLoose: [], gridLooseTexts: [], gridLooseFills: [], }; const source = store.source; if (!source || source.byteLength === 0) { if (debugEnabled()) console.log('[annotations] skip: missing/empty source'); return result; } // Skip the full-source WASM scan only when the model has neither IfcAnnotation // nor IfcGridAxis — this parse path ALSO feeds the grid buckets (gridByStorey / // gridLoose*), so gating on IfcAnnotation alone would drop grid-only models. // The scan copies the entire IFC source into the WASM heap on the main thread, // so skipping it when there is nothing to find still matters. if (!hasEntityType(store, 'IfcAnnotation', 'IfcGridAxis')) { if (debugEnabled()) console.log('[annotations] skip: no IfcAnnotation/IfcGridAxis entities'); return result; } const hierarchy = store.spatialHierarchy; const elementToStorey = hierarchy?.elementToStorey; const storeyElevations = hierarchy?.storeyElevations; const processor = new GeometryProcessor(); try { await processor.init(); // SymbolicRepresentationCollection and each getPolyline/getCircle/getText/ // getFill item are wasm-bindgen handles owning WASM memory — free them // deterministically (AGENTS.md §7). Leaking them to GC lets the // FinalizationRegistry free them later against an already-grown/reused // shared dlmalloc heap, corrupting the allocator free-list. const collection = processor.parseSymbolicRepresentations(source); if (debugEnabled()) { console.log( `[annotations] parsed ${source.byteLength} bytes →`, collection ? `${collection.polylineCount} polylines, ${collection.circleCount} circles, ${collection.textCount} texts, ${collection.fillCount} fills` : 'null', ); } if (!collection) return result; try { if (collection.isEmpty) return result; // Resolve a bucket by elevation rather than by storey id. // // The legacy path used `elementToStorey` exclusively — which breaks for // 3DEXPERIENCE / IfcPlusPlus exports whose `IfcRelAggregates` leaves // storeys orphaned so `SpatialHierarchyBuilder` reports "No storeys // found". Those files still encode the elevation on each item's // geometry (the IfcCartesianPoint.Z), which the WASM extractor now // surfaces as `primitive.worldY`. Bucketing by Y means every annotation // lands at the right floor regardless of whether the spatial hierarchy // could be built. // // Priority: explicit primitive worldY → fall back to storey-table // elevation → null (loose bucket, renders at fallbackY). // // Bucket keys are millimetre-rounded Y so two storeys 1mm apart still // collapse to one bucket — that's the precision Revit etc. round to. const ensureBucket = ( expressId: number, primitiveWorldY: number, ifcType: string, ): AnnotationsForStorey | null => { let effectiveY: number | null = null; if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) { effectiveY = primitiveWorldY; } else { const storeyId = elementToStorey?.get(expressId); if (storeyId !== undefined) { const elev = storeyElevations?.get(storeyId); if (typeof elev === 'number' && Number.isFinite(elev)) effectiveY = elev; } } if (effectiveY === null) return null; const key = Math.round(effectiveY * 1000); // Issue #862: IfcGridAxis primitives land in a parallel bucket // collection so the renderer can section-clip + visibility-toggle // them independently of IfcAnnotation (text/dimension symbols). const storeyMap = ifcType === 'IfcGridAxis' ? result.gridByStorey : result.byStorey; let bucket = storeyMap.get(key); if (!bucket) { bucket = { storeyId: key, storeyElevation: effectiveY, lines: [], texts: [], fills: [], }; storeyMap.set(key, bucket); } return bucket; }; for (let i = 0; i < collection.polylineCount; i++) { const poly = collection.getPolyline(i); if (!poly) continue; try { if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue; const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType); const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose; const out = bucket ? bucket.lines : looseTarget; // poly.points is consumed synchronously here (not stored), so no copy needed. polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out); } finally { poly.free(); } } for (let i = 0; i < collection.circleCount; i++) { const circle = collection.getCircle(i); if (!circle) continue; try { if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue; const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType); const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose; const out = bucket ? bucket.lines : looseTarget; circleToSegments( circle.centerX, circle.centerY, circle.radius, circle.startAngle, circle.endAngle, circle.isFullCircle, out, ); } finally { circle.free(); } } for (let i = 0; i < collection.textCount; i++) { const text = collection.getText(i); if (!text) continue; try { if (text.ifcType !== 'IfcAnnotation' && text.ifcType !== 'IfcGridAxis') continue; // Skip empty literals so the renderer doesn't waste an instance slot. // Decode STEP escapes — `\X2\NNNN\X0\` (UTF-16 hex code units) and // `\X\NN` (Latin-1 hex byte). The Rust parser intentionally passes // the literal through verbatim; this is where the JS encoding // package gets applied. Without it, non-ASCII annotation labels // (e.g. CJK content) render as raw escape sequences in the atlas. const decoded = decodeIfcString(text.content); if (decoded.length === 0) continue; // Multi-line split: IfcTextLiteralWithExtent.SizeInY is the LAYOUT BOX // height, not the glyph cap height. The Rust extractor multiplies // SizeInY × 0.7 to recover a single-line cap; for multi-line literals // we further divide by line count and stack lines downward in world-Y. // Source: IFC4 spec — IfcPlanarExtent describes the bounding box of // the typeset string; one literal per line is the conventional // rendering model (matches BIMvision / Solibri / Revit). const lines = decoded.split(/\r?\n/).filter((l) => l.length > 0); if (lines.length === 0) continue; const perLineHeight = lines.length > 1 ? text.height / lines.length : text.height; // Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up // a little air between rows so descenders don't kiss the next cap. const lineSpacing = perLineHeight * 1.2; const bucket = ensureBucket(text.expressId, text.worldY, text.ifcType); const looseTextTarget = text.ifcType === 'IfcGridAxis' ? result.gridLooseTexts : result.looseTexts; // All annotation text — grid bubbles, dimension callouts, leader labels — // billboards to the camera so it stays legible in any view orientation // (top-down, eye-level, oblique). The shader rebuilds the quad in the // screen-aligned basis at render time. Authored orientation is intentionally // dropped: at oblique viewing angles, flat-in-plane text becomes a smeared // sliver of pixels (issue #812). Anchor + alignment are preserved, so each // label still sits at its authored insertion point. // Read per-instance style metadata. WASM emits these for grid // bubble parts (● fill / ○ outline / tag) and reserves them for // future IfcTextStyle resolution on regular annotation text. const colorA = text.colorA; const hasColor = colorA > 0; const textColor: [number, number, number, number] | undefined = hasColor ? [text.colorR, text.colorG, text.colorB, colorA] : undefined; const targetPx = text.targetPx > 0 ? text.targetPx : undefined; for (let li = 0; li < lines.length; li++) { const t2d: AnnotationText2D = { x: text.x, y: text.y, dirX: text.dirX, dirY: text.dirY, height: perLineHeight, content: lines[li], alignment: text.alignment, lineYOffset: -li * lineSpacing, billboard: true, color: textColor, targetPx, }; (bucket ? bucket.texts : looseTextTarget).push(t2d); } } finally { text.free(); } } for (let i = 0; i < collection.fillCount; i++) { const fill = collection.getFill(i); if (!fill) continue; try { if (fill.ifcType !== 'IfcAnnotation' && fill.ifcType !== 'IfcGridAxis') continue; // fill.points / fill.holesOffsets are getter results that may be views // into WASM memory; they're STORED into f2d (outlive this iteration), // so copy them before the handle is freed below. Element types match // the AnnotationFill2D fields (Float32Array / Uint32Array). const points = new Float32Array(fill.points); if (points.length < 6) continue; // <3 vertices = no polygon const holesOffsets = new Uint32Array(fill.holesOffsets); const f2d: AnnotationFill2D = { points, holesOffsets, color: [fill.fillR, fill.fillG, fill.fillB, fill.fillA], hatching: fill.hasHatching ? { spacing: fill.hatchSpacing, angle: fill.hatchAngle, angleSecondary: Number.isNaN(fill.hatchAngleSecondary) ? null : fill.hatchAngleSecondary, lineWidth: fill.hatchLineWidth, } : undefined, }; const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType); const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills; (bucket ? bucket.fills : looseFillTarget).push(f2d); } finally { fill.free(); } } } finally { collection.free(); } } finally { processor.dispose(); } return result; } /** * Lift 2D annotation lines (renderer XZ space) to a flat Float32Array of * 3D line-list vertices `[x1, y, z1, x2, y, z2, …]`. The Y coordinate is * the annotation's storey elevation in world space, so the resulting * lines render at the right floor when drawn through the renderer's * world-space line pipeline. * * Exported for unit testing. */ export function liftTo3DLineList( lines: DrawingLine2D[], y: number, out: number[], ): void { for (const line of lines) { out.push(line.line.start.x, y, line.line.start.y); out.push(line.line.end.x, y, line.line.end.y); } } /** * Returns IFC annotation segments as a single Float32Array of pre-lifted 3D * line-list vertices in world space, ready to feed * `renderer.uploadAnnotationLines3D`. * * Each annotation is lifted to its containing storey's elevation. Annotations * with no resolvable storey fall back to `fallbackY` (typically the mid-Y of * the scene bounds) so the overlay stays visible even when the IFC file's * spatial hierarchy doesn't link annotations to a storey — common when the * authoring tool encodes the storey Z directly on the placement point * instead of on `IfcBuildingStorey.Elevation`. * * When `enabled` is false (toggle off, no models, etc.) the hook does no * parse work and returns a stable empty Float32Array. Parsing is lazy — * the WASM `parseSymbolicRepresentations` call only runs after the toggle * is turned on, and the result is cached per model source. */ const EMPTY_F32 = new Float32Array(0); // ─── Shared parse cache ───────────────────────────────────────────────────── // Parsing the whole file's symbolic representations is not cheap (full WASM // walk over every product's representations). Cache results module-globally // so the line / text / fill hooks share one parse per model source instead // of triggering it once per hook. const PARSE_CACHE = new Map(); const PARSE_INFLIGHT = new Map>(); /** Subscribers that want to re-render when a new parse result lands. */ type CacheListener = () => void; const CACHE_LISTENERS = new Set(); function notifyCacheChange(): void { for (const fn of CACHE_LISTENERS) fn(); } function ensureParseFor(stores: IfcDataStore[]): void { for (const store of stores) { const key = sourceKey(store); if (!key) continue; if (PARSE_CACHE.has(key)) continue; if (PARSE_INFLIGHT.has(key)) continue; const promise = (async () => { try { const result = await parseAnnotations(store); PARSE_CACHE.set(key, result); notifyCacheChange(); } catch (error) { // eslint-disable-next-line no-console console.warn('[useSymbolicAnnotations] parse failed:', error); } finally { PARSE_INFLIGHT.delete(key); } })(); PARSE_INFLIGHT.set(key, promise); } } /** Read the active store set from the viewer store. Federation-aware. */ function useActiveStores(): IfcDataStore[] { const { models, ifcDataStore } = useViewerStore( useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })), ); return useMemo(() => { const out: IfcDataStore[] = []; if (models.size > 0) { for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore); } else if (ifcDataStore) { out.push(ifcDataStore); } return out; }, [models, ifcDataStore]); } /** Trigger parse for the active stores when `enabled`, tick on completion. */ function useAnnotationParseTrigger(enabled: boolean, stores: IfcDataStore[]): number { const [version, setVersion] = useState(0); useEffect(() => { if (!enabled) return undefined; ensureParseFor(stores); const listener: CacheListener = () => setVersion((v) => v + 1); CACHE_LISTENERS.add(listener); return () => { CACHE_LISTENERS.delete(listener); }; }, [enabled, stores]); return version; } /** Resolve the world-space Y for a storey bucket. * * `null` elevation means the storey carried no value in the parsed metadata * (rare but happens in older authoring tools that leave * `IfcBuildingStorey.Elevation` blank and bake the Z into the placements); * fall back to the caller's `fallbackY` (typically the model's mid-Y). A * real ground floor at 0.0 keeps its authored 0 instead of being remapped. */ function resolveBucketY(elevation: number | null, fallbackY: number): number { return elevation === null ? fallbackY : elevation; } /** Section-clip parameters for grid lines (issue #862). Grids ARE clipped * by the active section plane; IfcAnnotation overlays are NOT (per the * feedback_3d_annotation_overlay_no_section_filter memory). When * `enabled === false` no clipping happens — grids lift to every storey * same as annotations. */ export interface SectionClipForGrid { enabled: boolean; /** World coord on the cut axis (e.g. world-Y for axis='down'). */ posWorld: number; /** Half-thickness of the visible band around the cut, world units. */ viewDepth: number; /** Cut axis. Only `'down'` performs vertical clipping; other axes pass through unfiltered (grid lines are vertical and don't project meaningfully onto elevation cuts). */ axis: 'down' | 'front' | 'side'; } export function useSymbolicAnnotations(params: { /** Enable IfcAnnotation lift (the existing default behaviour). */ enabled: boolean; /** * Enable IfcGrid lift. Independent of `enabled` so a user can hide * annotations while keeping grids, or vice versa (issue #862). * Defaults to `enabled` so existing call sites that don't set it * keep the legacy combined behaviour. */ gridEnabled?: boolean; /** Section clipping for grids only — see [`SectionClipForGrid`]. */ gridSectionClip?: SectionClipForGrid; /** World Y to use for annotations with no resolvable storey. Defaults to 0. */ fallbackY?: number; }): Float32Array { const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params; const effectiveGridEnabled = gridEnabled ?? enabled; const stores = useActiveStores(); // Trigger parse if EITHER subset is enabled — the parse pass is shared. const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores); const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down'; const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0; const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0; return useMemo(() => { if (!enabled && !effectiveGridEnabled) return EMPTY_F32; void version; // depend on parse-completion ticks const verts: number[] = []; let storeIdx = 0; for (const store of stores) { const key = sourceKey(store); if (!key) { storeIdx++; continue; } const cached = PARSE_CACHE.get(key); if (!cached) { if (debugEnabled()) console.log(`[annotations] store ${storeIdx}: parse not yet ready for key=${key}`); storeIdx++; continue; } if (debugEnabled()) { console.log( `[annotations] store ${storeIdx}: annotation buckets=${cached.byStorey.size}+${cached.loose.length}loose, grid buckets=${cached.gridByStorey.size}+${cached.gridLoose.length}loose (annot=${enabled}, grid=${effectiveGridEnabled}, clip=${clipEnabled})`, ); } if (enabled) { for (const bucket of cached.byStorey.values()) { liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts); } liftTo3DLineList(cached.loose, fallbackY, verts); } if (effectiveGridEnabled) { // Issue #862: section-clip grid buckets only — IfcAnnotation // intentionally bypasses this per the feedback memory ("the // user expects every storey's dimensions/grid bubbles to lift // into the viewport when [the annotation toggle is] on, even // while a section cut is active"). if (clipEnabled) { const lo = clipPos - clipDepth; const hi = clipPos + clipDepth; for (const bucket of cached.gridByStorey.values()) { const y = resolveBucketY(bucket.storeyElevation, fallbackY); if (y < lo || y > hi) continue; liftTo3DLineList(bucket.lines, y, verts); } if (fallbackY >= lo && fallbackY <= hi) { liftTo3DLineList(cached.gridLoose, fallbackY, verts); } } else { for (const bucket of cached.gridByStorey.values()) { liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts); } liftTo3DLineList(cached.gridLoose, fallbackY, verts); } } storeIdx++; } if (debugEnabled()) console.log(`[annotations] total 3D line vertices: ${verts.length / 3} from ${stores.length} stores`); if (verts.length === 0) return EMPTY_F32; return new Float32Array(verts); }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]); } /** * A text annotation lifted into 3D world space. * * `worldPos[1]` is the storey Y the annotation belongs to (or `fallbackY` for * orphans). `dirX / dirZ` is the baseline direction in 3D (already mirrored * from the IFC frame to match the section overlay's coordinate handedness). * `height` is in world units. */ export interface AnnotationText3D { worldPos: [number, number, number]; dirX: number; dirZ: number; height: number; content: string; alignment: string; /** True when the glyph quad should rebuild in camera-aligned basis (grid tags). */ billboard?: boolean; /** sRGB straight-alpha tint, 0..1. */ color?: [number, number, number, number]; /** Per-instance target cap height in screen pixels. */ targetPx?: number; } /** * A filled region lifted into 3D world space. `points` is a flat * `[x, z, x, z, …]` ring buffer (Y is constant = `worldY`). Holes are tracked * via `holesOffsets` (vertex indices into `points`); the renderer triangulates. */ export interface AnnotationFill3D { points: Float32Array; holesOffsets: Uint32Array; worldY: number; color: [number, number, number, number]; hatching?: AnnotationFill2D['hatching']; } /** Cheap stable empty arrays for the no-data path. */ const EMPTY_TEXTS: readonly AnnotationText3D[] = Object.freeze([]); const EMPTY_FILLS: readonly AnnotationFill3D[] = Object.freeze([]); /** * Hook for the 2D Section panel: filters the shared parse cache to * annotations whose world position falls inside the section's view-range * on the cut axis, returning data in the Drawing2D coordinate frame. * * For `axis='down'` (floor plan), the parser's 2D coords already match * the drawing-2d coord frame directly (x = world x, y = world z, with * worldY = the cut axis). For elevation views (`axis='front'`, * `axis='side'`), this hook returns empty: most authored IFC annotations * are floor-plan symbols (dimensions, leaders, room labels) and don't * project meaningfully onto a vertical drawing without a separate * reorientation pass. Wiring those up cleanly is a follow-up. * * The section position is in world units (already converted from the * 0-100% slider via `axisMin + (position / 100) * (axisMax - axisMin)` * by the caller — Section2DPanel computes the same value to feed the * drawing generator). */ export interface DrawingAnnotationData { lines: DrawingLine2D[]; texts: AnnotationText2D[]; fills: AnnotationFill2D[]; } const EMPTY_DRAWING_ANNOTATIONS: DrawingAnnotationData = { lines: [], texts: [], fills: [], }; export function useSymbolicAnnotationsForDrawing(params: { enabled: boolean; axis: 'down' | 'front' | 'side'; /** Section plane world-coord position along the cut axis. */ sectionPosWorld: number; /** View depth in world units (typically half the model extent on the cut axis). */ viewDepth: number; flipped: boolean; /** Fallback world Y for annotations with no resolvable storey. */ fallbackY?: number; }): DrawingAnnotationData { const { enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY = 0 } = params; const stores = useActiveStores(); const version = useAnnotationParseTrigger(enabled, stores); return useMemo(() => { if (!enabled) return EMPTY_DRAWING_ANNOTATIONS; // Only floor plans (axis='down') are supported on this pass. Annotations // for elevations/sections need a coord-reorientation pass that is not // worth building until there's a real authored elevation symbol to test // against. Returning empty quietly keeps the toggle a no-op there. if (axis !== 'down') return EMPTY_DRAWING_ANNOTATIONS; void version; // Section view range in world Y. // // For a floor-plan cut at axis='down' the camera looks DOWN through the // cut. "In front of the camera" is therefore the side BELOW the cut — // where the floor and authored dimensions sit (IFC convention places // dimension annotations at the storey's floor elevation, not at the // cut height). The user's complaint: with the slab on the +normal // side, you had to scrub the section DOWN into the floor before // anything showed, and then the dimensions appeared one storey BELOW // the cut. Mirror that — keep the slab on the −normal side for the // unflipped down section, and flip it for the reflected-ceiling case. // // Note this DIVERGES from `profile-projector.isInProjectionRange`, // which projects above the cut by default. Annotations live with the // storey floor, the projection lives with the upper-storey volume — // they're naturally on opposite sides of the cut plane. // // Tolerance lets annotations authored exactly on the cut plane (e.g. // a storey at Z=0 with a section right at the storey datum) survive. const TOL = 1e-3; const rangeMin = (flipped ? sectionPosWorld : sectionPosWorld - viewDepth) - TOL; const rangeMax = (flipped ? sectionPosWorld + viewDepth : sectionPosWorld) + TOL; const lines: DrawingLine2D[] = []; const texts: AnnotationText2D[] = []; const fills: AnnotationFill2D[] = []; // The drawing-2d cutter negates the 2D U axis on flipped cardinal cuts // (see `projectTo2D` in @ifc-lite/drawing-2d/math.ts and `flipU` in the // GPU cutter). Annotation primitives come out of WASM in the cutter's // UNFLIPPED basis, so on a flipped section they'd sit beside the model // (mirrored across X=0) instead of on top of it — exactly the // "dimensions floating to the right of the floor plan" symptom. Mirror // X for lines/texts/fills here so they line up with the section cut // output drawn underneath. Y stays put (the cutter only flips U). const pushLine = flipped ? (ln: DrawingLine2D) => lines.push({ line: { start: { x: -ln.line.start.x, y: ln.line.start.y }, end: { x: -ln.line.end.x, y: ln.line.end.y }, }, category: ln.category, }) : (ln: DrawingLine2D) => lines.push(ln); const pushText = flipped ? (t: AnnotationText2D) => texts.push({ ...t, x: -t.x, dirX: -t.dirX }) : (t: AnnotationText2D) => texts.push(t); const pushFill = flipped ? (f: AnnotationFill2D) => { const src = f.points; const dst = new Float32Array(src.length); for (let i = 0; i < src.length; i += 2) { dst[i] = -src[i]; dst[i + 1] = src[i + 1]; } fills.push({ ...f, points: dst }); } : (f: AnnotationFill2D) => fills.push(f); for (const store of stores) { const key = sourceKey(store); if (!key) continue; const cached = PARSE_CACHE.get(key); if (!cached) continue; // Drawing-2D pulls BOTH annotation and grid buckets (issue #862 // split them at parse time so the 3D viewport can clip them // separately — the 2D Section panel still wants the combined // overlay). const collectBucket = (bucket: AnnotationsForStorey) => { const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY); if (bucketY < rangeMin || bucketY > rangeMax) return; for (const ln of bucket.lines) pushLine(ln); for (const t of bucket.texts) pushText(t); for (const f of bucket.fills) pushFill(f); }; for (const bucket of cached.byStorey.values()) collectBucket(bucket); for (const bucket of cached.gridByStorey.values()) collectBucket(bucket); // Loose annotations have no resolvable storey — include them if the // fallback Y lands in the view range. That keeps malformed exports // (e.g. 3DEXPERIENCE files with orphaned storeys) usable when the // user is looking at the storey the fallback resolves to. if (fallbackY >= rangeMin && fallbackY <= rangeMax) { for (const ln of cached.loose) pushLine(ln); for (const t of cached.looseTexts) pushText(t); for (const f of cached.looseFills) pushFill(f); for (const ln of cached.gridLoose) pushLine(ln); for (const t of cached.gridLooseTexts) pushText(t); for (const f of cached.gridLooseFills) pushFill(f); } } if (lines.length === 0 && texts.length === 0 && fills.length === 0) { return EMPTY_DRAWING_ANNOTATIONS; } return { lines, texts, fills }; }, [enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY, stores, version]); } /** * Hook for the WebGPU text + fill pipelines. Returns 3D-lifted texts and * fills for every active model. Shares the parse cache with * `useSymbolicAnnotations` so toggling on text+fill rendering after the * line overlay is already up costs no extra parse work. */ export function useSymbolicAnnotationsRichData(params: { enabled: boolean; /** Lift grid-bubble texts + fills. Independent of `enabled` (issue #862). * Defaults to `enabled` for legacy callers. */ gridEnabled?: boolean; /** Section clipping for grid texts/fills only — same semantics as * [`useSymbolicAnnotations`]. */ gridSectionClip?: SectionClipForGrid; fallbackY?: number; }): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } { const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params; const effectiveGridEnabled = gridEnabled ?? enabled; const stores = useActiveStores(); const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores); const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down'; const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0; const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0; return useMemo(() => { if (!enabled && !effectiveGridEnabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS }; void version; const texts: AnnotationText3D[] = []; const fills: AnnotationFill3D[] = []; for (const store of stores) { const key = sourceKey(store); if (!key) continue; const cached = PARSE_CACHE.get(key); if (!cached) continue; const pushText = (t: AnnotationText2D, y: number) => { // lineYOffset stacks multi-line text downward in world-Y. Glyph // upAxis is world-Y (see SymbolicTextPipeline), so subtracting // here puts line 1 below line 0 on screen for any side/oblique // 3D view of the floor plan. texts.push({ worldPos: [t.x, y + (t.lineYOffset ?? 0), t.y], dirX: t.dirX, dirZ: t.dirY, height: t.height, content: t.content, alignment: t.alignment, billboard: t.billboard, color: t.color, targetPx: t.targetPx, }); }; const pushFill = (f: AnnotationFill2D, y: number) => { fills.push({ points: f.points, holesOffsets: f.holesOffsets, worldY: y, color: f.color, hatching: f.hatching, }); }; if (enabled) { for (const bucket of cached.byStorey.values()) { const y = resolveBucketY(bucket.storeyElevation, fallbackY); for (const t of bucket.texts) pushText(t, y); for (const f of bucket.fills) pushFill(f, y); } for (const t of cached.looseTexts) pushText(t, fallbackY); for (const f of cached.looseFills) pushFill(f, fallbackY); } if (effectiveGridEnabled) { if (clipEnabled) { const lo = clipPos - clipDepth; const hi = clipPos + clipDepth; for (const bucket of cached.gridByStorey.values()) { const y = resolveBucketY(bucket.storeyElevation, fallbackY); if (y < lo || y > hi) continue; for (const t of bucket.texts) pushText(t, y); for (const f of bucket.fills) pushFill(f, y); } if (fallbackY >= lo && fallbackY <= hi) { for (const t of cached.gridLooseTexts) pushText(t, fallbackY); for (const f of cached.gridLooseFills) pushFill(f, fallbackY); } } else { for (const bucket of cached.gridByStorey.values()) { const y = resolveBucketY(bucket.storeyElevation, fallbackY); for (const t of bucket.texts) pushText(t, y); for (const f of bucket.fills) pushFill(f, y); } for (const t of cached.gridLooseTexts) pushText(t, fallbackY); for (const f of cached.gridLooseFills) pushFill(f, fallbackY); } } } return { texts: texts.length ? texts : EMPTY_TEXTS, fills: fills.length ? fills : EMPTY_FILLS, }; }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]); }