/* 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/. */ /** * useDrawingGeneration - Custom hook for 2D drawing generation logic * * Extracts the drawing generation pipeline from Section2DPanel, including: * - Section cut generation via Drawing2DGenerator * - Symbolic representation parsing and caching * - Hybrid drawing creation (symbolic + section cut) * - Bounding box alignment for symbolic lines * - Auto-generation effects (panel open, overlay enable, geometry change) * - Section plane change detection with overlap protection */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Drawing2DGenerator, createSectionConfig, currentFloorBands, storeyFloorsFromMeshes, type Drawing2D, type DrawingLine, type SectionConfig, type ProfileEntry, type MeshOutline2D, } from '@ifc-lite/drawing-2d'; import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry'; import type { SpatialHierarchy } from '@ifc-lite/data'; import * as IfcWasm from '@ifc-lite/wasm'; import { customPlaneCenter } from '@/store'; // The winding-robust Rust `meshOutline2d` binding (issue #979) is gitignored → // CI-built, so reference it defensively: against an older wasm bundle it's // undefined and projection falls back to the TS mesh silhouette. The wasm // module is already initialised (the model loaded through it), so the free // function can be called without a GeometryProcessor instance. interface MeshOutlineHandle { readonly axisMin: number; readonly axisMax: number; readonly contourCount: number; contour(index: number): Float32Array | undefined; free(): void; } type MeshOutline2dFn = ( positions: Float32Array, indices: Uint32Array, axis: number, flipped: boolean, ) => MeshOutlineHandle | undefined; const meshOutline2dFn = (IfcWasm as unknown as { meshOutline2d?: MeshOutline2dFn }).meshOutline2d; const AXIS_CODE: Record<'x' | 'y' | 'z', number> = { x: 0, y: 1, z: 2 }; // Axis conversion from semantic (down/front/side) to geometric (x/y/z) export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = { down: 'y', front: 'z', side: 'x', }; // Depth of the slab IN FRONT of the section plane (in shifted-world // metres) within which IFC annotation/grid primitives are kept. Beyond // the slab they're culled — matches a typical plan-view "view depth" // where dimensions for the next storey shouldn't bleed through. The // shifted-bounds coordinate system the centroids and `position` both // live in is already in metres (WASM applies `unit_scale` upstream). export const ANNOTATION_VIEW_DEPTH = 1.2; // View depth BEHIND a vertical (front/side) section cut within which // construction projection is drawn, as a fraction of the model extent along the // cut axis (issue #979 follow-up). A vertical section has no "storey" to scope // to, so it projects a bounded slab behind the cut — near geometry solid, // occluded/far dashed (hidden-line pass) — and culls the cut-away front half // and anything past this depth. Half the model depth is a sensible default; // tune here if sections feel too deep or too shallow. export const SECTION_VIEW_DEPTH_FRACTION = 0.5; interface UseDrawingGenerationParams { geometryResult: GeometryResult | null | undefined; // `spatialHierarchy` (optional — absent on cache-reopened models) backs the // current-floor projection scoping (issue #979 follow-up). The runtime // already passes the full DataStore from `useIfc()`, so this is a pure type // widen, not new prop threading. ifcDataStore: { source: Uint8Array; spatialHierarchy?: SpatialHierarchy } | null; /** * Section plane state. `custom` is the optional face-pick override * (issue #243); when set the cutter cuts on that arbitrary plane and * the cap basis flows from `custom.tangent`/`bitangent` so the cap * silhouette lands precisely on the tilted plane. */ sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean; custom?: { normal: [number, number, number]; distance: number; pickedAt: [number, number, number]; tangent: [number, number, number]; bitangent: [number, number, number]; }; }; displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number; showConstructionProjection: boolean }; combinedHiddenIds: Set; combinedIsolatedIds: Set | null; computedIsolatedIds?: Set | null; models: Map; panelVisible: boolean; drawing: Drawing2D | null; // Store actions setDrawing: (d: Drawing2D | null) => void; setDrawingStatus: (s: 'idle' | 'generating' | 'ready' | 'error') => void; setDrawingProgress: (p: number, phase: string) => void; setDrawingError: (e: string | null) => void; } interface UseDrawingGenerationResult { generateDrawing: (isRegenerate?: boolean) => Promise; doRegenerate: () => Promise; isRegenerating: boolean; } export function useDrawingGeneration({ geometryResult, ifcDataStore, sectionPlane, displayOptions, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, models, panelVisible, drawing, setDrawing, setDrawingStatus, setDrawingProgress, setDrawingError, }: UseDrawingGenerationParams): UseDrawingGenerationResult { // Track if this is a regeneration (vs initial generation) const isRegeneratingRef = useRef(false); // Symbolic lines carry the parent primitive's world-space centroid so the // 2D Section filter below can cull them against the active cut plane — // cardinal axis OR a face-picked custom plane. The drawing-2d package's // DrawingLine has no per-line position slot; attaching the centroid as // extra fields keeps the change local since the canvas ignores anything // beyond DrawingLine's declared fields. // // Coordinate space matches the section cutter's input (shifted bounds): // - worldX: read from the polyline's 2D x (already RTC-shifted by WASM) // - worldZ: -(polyline 2D y) — WASM negates Z into the 2D y axis to // match section-cut output handedness, so flip back here // - worldY: from the WASM `worldY` accessor (vertical elevation) type SymbolicDrawingLine = DrawingLine & { worldX?: number; worldY?: number; worldZ?: number; }; // Cache for symbolic representations - these don't change with section position // Only re-parse when model or display options change const symbolicCacheRef = useRef<{ lines: SymbolicDrawingLine[]; entities: Set; sourceId: string | null; useSymbolic: boolean; } | null>(null); // Cache for extracted extruded-solid profiles (issue #979 construction // projection). Like symbolic reps these are section-position-independent, so // they're parsed once per model and reused across section moves. Every typed // array is copied off the WASM heap (`.slice()`) and the WASM handles freed // deterministically before caching — caching a live view would dangle once // the shared dlmalloc heap grows/reuses (AGENTS.md §7). const profileCacheRef = useRef<{ profiles: ProfileEntry[]; sourceId: string | null; } | null>(null); // Cache for per-storey floor levels used to scope construction projection to // the current floor (issue #979 follow-up). Derived from mesh-Y, so it only // changes when the model/visibility set changes — keyed on the same // `modelCacheKey` as the profile cache. const storeyFloorsCacheRef = useRef<{ floors: number[]; sourceId: string | null; } | null>(null); // Generate drawing when panel opens const generateDrawing = useCallback(async (isRegenerate = false) => { if (!geometryResult?.meshes || geometryResult.meshes.length === 0) { // Clear the drawing when no geometry is available (e.g., all models hidden) setDrawing(null); setDrawingStatus('idle'); setDrawingError('No visible geometry'); return; } // Only show full loading overlay for initial generation, not regeneration if (!isRegenerate) { setDrawingStatus('generating'); setDrawingProgress(0, 'Initializing...'); } isRegeneratingRef.current = isRegenerate; // Parse symbolic representations if enabled (for hybrid mode) // OPTIMIZATION: Cache symbolic data - it doesn't change with section position let symbolicLines: SymbolicDrawingLine[] = []; let entitiesWithSymbols = new Set(); // For multi-model: create cache key from model count and visible model IDs // For single-model: use source byteLength as before const modelCacheKey = models.size > 0 ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join('|')}` : (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null); const useSymbolic = displayOptions.useSymbolicRepresentations && !!ifcDataStore?.source; // Check if we can use cached symbolic data const cache = symbolicCacheRef.current; const cacheValid = cache && cache.sourceId === modelCacheKey && cache.useSymbolic === useSymbolic; if (useSymbolic) { if (cacheValid) { // Use cached data - FAST PATH symbolicLines = cache.lines; entitiesWithSymbols = cache.entities; } else { // Need to parse - only on first load or when model changes try { if (!isRegenerate) { setDrawingProgress(5, 'Parsing symbolic representations...'); } const processor = new GeometryProcessor(); try { await processor.init(); // SymbolicRepresentationCollection and each getPolyline/getCircle // 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 symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source); // For single-model (legacy) mode, model index is always 0 // Multi-model symbolic parsing would require iterating over each model separately const symbolicModelIndex = 0; if (symbolicCollection) { try { if (!symbolicCollection.isEmpty) { // Process polylines for (let i = 0; i < symbolicCollection.polylineCount; i++) { const poly = symbolicCollection.getPolyline(i); if (!poly) continue; try { entitiesWithSymbols.add(poly.expressId); // poly.points is consumed synchronously within this iteration // (centroid sum + segment pushes read scalar values out of it); // the array itself is never stored, so no copy is needed. const points = poly.points; const pointCount = poly.pointCount; // WASM exposes `worldY` on every symbolic primitive — the // elevation of its parent placement (Z-up IFC, world-Y here). // The .d.ts shipped with the @ifc-lite/wasm package lags // behind the Rust source; read defensively so a stale build // returns undefined instead of throwing. const polyWorldY = (poly as unknown as { worldY?: number }).worldY; // Centroid in shifted world coords — derived from the 2D // points the WASM extractor already emits in section-cut // space. point.x = world X (RTC-shifted); point.y = // -world Z (negated to match cut-output handedness), so // flip the sign back to recover world Z. Computed once // per source polyline and shared across its segments. let sumX = 0; let sumY = 0; for (let p = 0; p < pointCount; p++) { sumX += points[p * 2]; sumY += points[p * 2 + 1]; } const polyWorldX = pointCount > 0 ? sumX / pointCount : undefined; const polyWorldZ = pointCount > 0 ? -sumY / pointCount : undefined; for (let j = 0; j < pointCount - 1; j++) { symbolicLines.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: 'silhouette', visibility: 'visible', entityId: poly.expressId, ifcType: poly.ifcType, modelIndex: symbolicModelIndex, depth: 0, worldX: polyWorldX, worldY: polyWorldY, worldZ: polyWorldZ, }); } if (poly.isClosed && pointCount > 2) { symbolicLines.push({ line: { start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] }, end: { x: points[0], y: points[1] } }, category: 'silhouette', visibility: 'visible', entityId: poly.expressId, ifcType: poly.ifcType, modelIndex: symbolicModelIndex, depth: 0, worldX: polyWorldX, worldY: polyWorldY, worldZ: polyWorldZ, }); } } finally { poly.free(); } } // Process circles/arcs for (let i = 0; i < symbolicCollection.circleCount; i++) { const circle = symbolicCollection.getCircle(i); if (!circle) continue; try { entitiesWithSymbols.add(circle.expressId); const numSegments = circle.isFullCircle ? 32 : 16; const circleWorldY = (circle as unknown as { worldY?: number }).worldY; // Centre in shifted world coords. circle.centerX is // already RTC-shifted X; circle.centerY carries the // negated Z (see polyline note above) — flip to recover. const circleWorldX = circle.centerX; const circleWorldZ = -circle.centerY; for (let j = 0; j < numSegments; j++) { const t1 = j / numSegments; const t2 = (j + 1) / numSegments; const a1 = circle.startAngle + t1 * (circle.endAngle - circle.startAngle); const a2 = circle.startAngle + t2 * (circle.endAngle - circle.startAngle); symbolicLines.push({ line: { start: { x: circle.centerX + circle.radius * Math.cos(a1), y: circle.centerY + circle.radius * Math.sin(a1), }, end: { x: circle.centerX + circle.radius * Math.cos(a2), y: circle.centerY + circle.radius * Math.sin(a2), }, }, category: 'silhouette', visibility: 'visible', entityId: circle.expressId, ifcType: circle.ifcType, modelIndex: symbolicModelIndex, depth: 0, worldX: circleWorldX, worldY: circleWorldY, worldZ: circleWorldZ, }); } } finally { circle.free(); } } } } finally { symbolicCollection.free(); } } } finally { processor.dispose(); } // Cache the parsed data symbolicCacheRef.current = { lines: symbolicLines, entities: entitiesWithSymbols, sourceId: modelCacheKey, useSymbolic, }; } catch (error) { console.warn('Symbolic parsing failed:', error); symbolicLines = []; entitiesWithSymbols = new Set(); } } } else { // Clear cache if symbolic is disabled if (cache && cache.useSymbolic) { symbolicCacheRef.current = null; } } // Construction projection runs on any CARDINAL cut (plan 'down' + vertical // 'front'/'side'), but NOT a face-picked custom plane (the band classifier // and outline binding are cardinal-only). Plan and section use different // boundaries: plan scopes to the current storey; a vertical section has no // "storey", so it projects a bounded view depth behind the cut (see the // band computation below). The UI gates the toggle to the same set; the // persisted flag can survive a switch to a custom plane, so gate here too. const projectionSupported = !sectionPlane.custom; const projectionOn = projectionSupported && displayOptions.showConstructionProjection; // ── Construction projection profiles (issue #979) ──────────────────────── // Extract extruded-area-solid profiles for the clean projection path. Only // for PLAN cuts: the profile projector draws a solid's base footprint, which // is the plan representation but collapses to a base edge on a vertical // section — so front/side cuts use the mesh-silhouette/outline path instead // (profiles stay empty → every mesh silhouettes). Cached per model since // they don't move with the section. Single-model (modelIndex 0) for now, // mirroring the symbolic path's federation limitation. const profilesNeeded = projectionOn && sectionPlane.axis === 'down'; let profiles: ProfileEntry[] = []; if (profilesNeeded && ifcDataStore?.source) { const pcache = profileCacheRef.current; if (pcache && pcache.sourceId === modelCacheKey) { profiles = pcache.profiles; } else { if (!isRegenerate) { setDrawingProgress(10, 'Extracting profiles...'); } try { const processor = new GeometryProcessor(); try { await processor.init(); // ProfileCollection + each ProfileEntryJs are WASM-bindgen handles // owning WASM memory. Copy every typed array off the heap with // `.slice()` and free each handle deterministically before caching // (AGENTS.md §7 — leaking to GC corrupts the shared dlmalloc heap). const collection = processor.extractProfiles(ifcDataStore.source, 0); if (collection) { try { // Profiles come back in UNSHIFTED WebGL world space, but the // meshes and the section position live in the render frame // (issue #945 RTC / large-coordinate shift). Subtract the same // shift so projection lines land on the cut geometry for // georeferenced models — a no-op for small-coordinate models // (AC20). The WASM mesh path subtracts the RTC offset in IFC // Z-up then converts to Y-up via (x,y,z)→(x,z,−y), so the Y-up // shift is (rtc.x, rtc.z, −rtc.y); the TS path instead // subtracts `originShift`, already in Y-up. const ci = geometryResult.coordinateInfo; const rtc = ci.wasmRtcOffset; const shift = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : ci.originShift; const len = collection.length; for (let i = 0; i < len; i++) { const entry = collection.get(i); if (!entry) continue; try { const transform = entry.transform.slice(); transform[12] -= shift.x; transform[13] -= shift.y; transform[14] -= shift.z; profiles.push({ expressId: entry.expressId, ifcType: entry.ifcType, outerPoints: entry.outerPoints.slice(), holeCounts: entry.holeCounts.slice(), holePoints: entry.holePoints.slice(), transform, extrusionDir: entry.extrusionDir.slice(), extrusionDepth: entry.extrusionDepth, modelIndex: 0, }); } finally { entry.free(); } } } finally { collection.free(); } } profileCacheRef.current = { profiles, sourceId: modelCacheKey }; } finally { processor.dispose(); } } catch (error) { // Degrade gracefully: the drawing still renders without projection. console.warn('Profile extraction failed:', error); profiles = []; } } } else if (!projectionOn && profileCacheRef.current) { // Projection fully off: drop the cache so a re-enable re-extracts cleanly. // A plan↔section switch (projection still on) keeps the cache so flipping // back to a plan reuses the extracted profiles. profileCacheRef.current = null; } let generator: Drawing2DGenerator | null = null; try { generator = new Drawing2DGenerator(); await generator.initialize(); // Convert semantic axis to geometric const axis = AXIS_MAP[sectionPlane.axis]; // Calculate section position from percentage using coordinateInfo bounds const bounds = geometryResult.coordinateInfo.shiftedBounds; const axisMin = bounds.min[axis]; const axisMax = bounds.max[axis]; const position = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin); // Calculate max depth as half the model extent const maxDepth = (axisMax - axisMin) * 0.5; // Construction-projection bands (issue #979 + current-floor follow-up). // Project geometry on each side of the cut and let the band classifier // split it (below → solid, above → dashed). `fullExtent` (the whole model // height) is the baseline; for a multi-storey model on a plan cut the // bands are instead clamped to the storey the cut sits in, so other // floors don't bleed onto the plan (e.g. a roof two levels up — the // reported bug). Flip-invariant: the classifier applies the flip sign // itself. Floor at 1mm so a degenerate zero-extent model (or a storey // collapsed to a single slab) doesn't yield 0-width bands that cull every // element sitting on the plane. const fullExtent = Math.max(axisMax - axisMin, 1e-3); let belowDepth = fullExtent; let aboveDepth = fullExtent; // Auto-scope to the current floor only when it's safe and meaningful: // a plan ('down') cut with projection on, a single model (storey ids are // LOCAL express ids — federation would mismatch global mesh ids), no // active manual isolation or storey selection (those already scope the // set and the user's explicit choice wins), spatial-hierarchy data // present (absent on cache-reopened models), and at most ONE building. // A single IFC can hold several IfcBuildings with staggered storey // elevations; flattening all their storey minima into one band mis-scopes // (a cut on building B's ground floor capped by building A's upper // storey), so multi-building models fall back to full extent too. // Otherwise keep the shipped full-extent behavior so single-storey / // cache-loaded / federated / multi-building models don't regress. const sh = ifcDataStore?.spatialHierarchy; const canScopeFloor = projectionOn && sectionPlane.axis === 'down' && !sectionPlane.custom && models.size <= 1 && combinedIsolatedIds === null && !(computedIsolatedIds && computedIsolatedIds.size > 0) && sh !== undefined && sh.byBuilding.size <= 1; if (canScopeFloor && sh) { const cached = storeyFloorsCacheRef.current; const floors = cached && cached.sourceId === modelCacheKey ? cached.floors : storeyFloorsFromMeshes(geometryResult.meshes, sh.elementToStorey); if (!cached || cached.sourceId !== modelCacheKey) { storeyFloorsCacheRef.current = { floors, sourceId: modelCacheKey }; } // Need ≥2 storeys to scope: with 0/1 storey there is no "other floor" // to exclude, and full extent keeps an overhead roof projecting. if (floors.length >= 2) { // `currentFloorBands` returns GEOMETRIC depths — `below` toward the // floor, `above` toward the ceiling. The band classifier reads them // in FLIP-ADJUSTED depth space (d<0 = `below` slot), so on a flipped // plan cut (looking up — a reflected-ceiling-plan style view) the // floor/ceiling map to the opposite slots and the magnitudes must be // swapped. The shipped full-extent bands were symmetric so this never // mattered before; the asymmetric storey bands make flip significant. const bands = currentFloorBands(floors, position, axisMin, axisMax); belowDepth = sectionPlane.flipped ? bands.above : bands.below; aboveDepth = sectionPlane.flipped ? bands.below : bands.above; } } // Vertical section (front/side): storeys don't bound it. Project a // bounded view depth BEHIND the cut and cull the cut-away front half + // anything past that depth. "Behind" is always the `below` (d<0) band: // the band classifier's flip and the view direction's flip cancel, so // this is flip-invariant (no swap needed). Near geometry draws solid; // the hidden-line pass dashes occluded/far parts. (Profiles aren't // extracted off-plan, so this geometry comes from the mesh silhouette.) if (projectionOn && !sectionPlane.custom && sectionPlane.axis !== 'down') { belowDepth = Math.max((axisMax - axisMin) * SECTION_VIEW_DEPTH_FRACTION, 1e-3); aboveDepth = 1e-3; // cull the half in front of the cut } // Adjust progress to account for symbolic parsing phase (0-20%) const progressOffset = symbolicLines.length > 0 ? 20 : 0; const progressScale = symbolicLines.length > 0 ? 0.8 : 1; const progressCallback = (stage: string, prog: number) => { setDrawingProgress(progressOffset + prog * 100 * progressScale, stage); }; // Create section config const config: SectionConfig = createSectionConfig(axis, position, { projectionDepth: maxDepth, projectionBelowDepth: belowDepth, projectionAboveDepth: aboveDepth, includeHiddenLines: displayOptions.showHiddenLines, scale: displayOptions.scale, }); // Override the flipped setting config.plane.flipped = sectionPlane.flipped; // Face-pick custom plane (issue #243): hand the cutter the explicit // basis so its 2D output sits in the same coordinate system the cap // shader will lift back to 3D — without this the polygon and the // shader-clipped silhouette would disagree on every non-cardinal // pick (PR #581's bug). if (sectionPlane.custom) { const c = sectionPlane.custom; // Use the LIVE plane anchor (pickedAt projected onto the current // plane), not pickedAt itself. As the user drags the gizmo only // `distance` changes — pickedAt sits off the live plane, and // using it as the basis origin makes the round-trip lift drop // the normal-component, freezing the cap polygons at the // original pick location while the geometry clip slides. Using // the projected center keeps the basis origin ON the live plane // so the cutter's 2D points lift back to the actual cut surface. const origin = customPlaneCenter(c); config.plane.customPlane = { normal: { x: c.normal[0], y: c.normal[1], z: c.normal[2] }, distance: c.distance, origin: { x: origin[0], y: origin[1], z: origin[2] }, tangent: { x: c.tangent[0], y: c.tangent[1], z: c.tangent[2] }, bitangent: { x: c.bitangent[0], y: c.bitangent[1], z: c.bitangent[2] }, }; } // Filter meshes by visibility (respect 3D hiding/isolation) let meshesToProcess = geometryResult.meshes; // Filter out hidden entities (using combined multi-model set) if (combinedHiddenIds.size > 0) { meshesToProcess = meshesToProcess.filter( mesh => !combinedHiddenIds.has(mesh.expressId) ); } // Filter by isolation (if active, using combined multi-model set) if (combinedIsolatedIds !== null) { meshesToProcess = meshesToProcess.filter( mesh => combinedIsolatedIds.has(mesh.expressId) ); } // Also filter by computedIsolatedIds (storey selection) if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) { const isolatedSet = computedIsolatedIds; meshesToProcess = meshesToProcess.filter( mesh => isolatedSet.has(mesh.expressId) ); } // If all meshes were filtered out by visibility, clear the drawing if (meshesToProcess.length === 0) { setDrawing(null); setDrawingStatus('idle'); setDrawingError(null); return; } // Construction projection (issue #979): when enabled, project geometry // beyond the cut. The clean profile path handles extruded solids; the // silhouette path (includeEdges) covers non-extruded geometry — roofs, // stairs, site — that has no profile. The below/above band split drives // solid vs dashed; hidden-line removal (below `includeHiddenLines`) is an // additional occlusion pass the user controls via "show hidden lines". // Apply the SAME hiding/isolation filters to the profiles as to the // meshes, so projection respects 3D hiding and storey isolation — // otherwise other storeys' profiles project through the plan and the // dedup keys (built from profiles) would suppress silhouettes for // entities that aren't actually drawn. let projectionProfiles = profiles; if (projectionOn && profiles.length > 0) { if (combinedHiddenIds.size > 0) { projectionProfiles = projectionProfiles.filter((p) => !combinedHiddenIds.has(p.expressId)); } if (combinedIsolatedIds !== null) { projectionProfiles = projectionProfiles.filter((p) => combinedIsolatedIds.has(p.expressId)); } if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) { const isolatedSet = computedIsolatedIds; projectionProfiles = projectionProfiles.filter((p) => isolatedSet.has(p.expressId)); } } // Winding-robust outline provider for non-extruded geometry (roofs, // stairs, site). Calls the Rust meshOutline2d binding per mesh; each call // copies the contour data off the WASM heap and frees the handle inline. // Undefined when projection is off or the binding isn't in this wasm // build → the generator falls back to the TS mesh silhouette. const outlineProvider = projectionOn && typeof meshOutline2dFn === 'function' ? (mesh: { positions: Float32Array; indices: Uint32Array; origin?: readonly number[] }, axis: 'x' | 'y' | 'z', flipped: boolean): MeshOutline2D | null => { try { // Positions are in the element's local frame (world = origin + // position). Feed WORLD positions to the outline extractor so its // contours + axisMin/axisMax come back in the same render-frame // world space as the (origin-folded) section cut. No-op when the // origin is absent/[0,0,0]. const o = mesh.origin; let outlinePositions = mesh.positions; if (o && (o[0] !== 0 || o[1] !== 0 || o[2] !== 0)) { outlinePositions = new Float32Array(mesh.positions.length); for (let i = 0; i < mesh.positions.length; i += 3) { outlinePositions[i] = mesh.positions[i] + o[0]; outlinePositions[i + 1] = mesh.positions[i + 1] + o[1]; outlinePositions[i + 2] = mesh.positions[i + 2] + o[2]; } } const handle = meshOutline2dFn(outlinePositions, mesh.indices, AXIS_CODE[axis], flipped); if (!handle) return null; try { const contours: Float32Array[] = []; for (let i = 0; i < handle.contourCount; i++) { const ring = handle.contour(i); if (ring) contours.push(ring.slice()); // copy off the WASM heap } if (contours.length === 0) return null; return { contours, axisMin: handle.axisMin, axisMax: handle.axisMax }; } finally { handle.free(); } } catch { return null; // binding unavailable/failed → silhouette fallback } } : undefined; const result = await generator.generate( meshesToProcess, config, { // Respect the "show hidden lines" toggle: occlusion can downgrade // visible (below-cut) projection lines to dashed. Overhead lines stay // dashed regardless (the generator passes them through unchanged). includeHiddenLines: projectionOn ? displayOptions.showHiddenLines : false, includeProjection: projectionOn, includeEdges: projectionOn, mergeLines: true, outlineProvider, onProgress: progressCallback, }, projectionOn ? projectionProfiles : undefined, ); // If we have symbolic representations, create a hybrid drawing if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) { // Get entity IDs that actually appear in the section cut (these are being cut by the plane) const cutEntityIds = new Set(); for (const line of result.lines) { if (line.entityId !== undefined) { cutEntityIds.add(line.entityId); } } // Also check cut polygons for entity IDs for (const poly of result.cutPolygons ?? []) { if ((poly as { entityId?: number }).entityId !== undefined) { cutEntityIds.add((poly as { entityId?: number }).entityId!); } } // When the user toggles `sectionPlane.flipped` on a cardinal axis, // the cutter negates the 2D U axis (see `projectTo2D` in // @ifc-lite/drawing-2d/math.ts and `data[6] = flipU` in the GPU // cutter). Symbolic primitives come out of WASM in the cutter's // UNFLIPPED basis — for the plan ('y') case `(line.x = worldX − rtc, // line.y = −worldY + rtc)` — so on a flipped section the cut // polygons land at −X while the symbolic lines stay at +X. The // result the user reported: annotations sitting NEXT TO the model // as if they were mirrored across the model's centre, instead of // staying with the cut. Mirror symbolic X here to match the cutter // for cardinal flipped sections. Custom face-pick planes use // `projectTo2DBasis` (no U flip), so leave them untouched — // symbolic alignment on an arbitrary basis is a separate problem // and out of scope for this fix. const mirrorSymbolicX = sectionPlane.flipped && !sectionPlane.custom; const orientedSymbolicLines: SymbolicDrawingLine[] = mirrorSymbolicX ? symbolicLines.map((line) => ({ ...line, line: { start: { x: -line.line.start.x, y: line.line.start.y }, end: { x: -line.line.end.x, y: line.line.end.y }, }, })) : symbolicLines; // Cull annotations to a thin view-depth slab IN FRONT of the cut. // // IfcAnnotation / IfcGridAxis polylines (dimensions, room tags, grid // bubbles) live at a single elevation but have no body geometry — // the `cutEntityIds.has(line.entityId)` filter below never matches // them, so without this they render regardless of where the // section sits. // // Reduce every cut mode (cardinal X/Y/Z + face-pick custom plane) // to a single half-space test against a unit normal + signed // distance. For cardinal axes the normal is the basis vector and // distance is `position` (already in shifted-bounds coords, the // same space the symbolic centroids land in). For custom planes // the WASM cutter already uses `normal`/`distance` verbatim, so // re-use both here for consistency with the cap. // // The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on // the −normal side — the side BELOW a down-looking camera, where // IFC dimensions live (authored at the storey's floor elevation, // not at the cut height). Flipped sections look at the same world // from the opposite side, so the slab mirrors to // `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`. // // Anything on the wrong side of the cut, or farther than the view // depth on the right side, is dropped — without the upper bound, // dimensions from every storey beyond the cut stacked on top of // each other because the half-space alone is unbounded along the // camera axis. // // Annotations missing a recoverable centroid (older WASM build, // or a degenerate polyline) are kept — over-rendering is preferable // to silently dropping authored dimensions when the runtime can't // classify them. const cullNormal: [number, number, number] = sectionPlane.custom ? sectionPlane.custom.normal : axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]; const cullDistance = sectionPlane.custom ? sectionPlane.custom.distance : position; const annotationCulled = orientedSymbolicLines.filter((line) => { const isAnnotationLike = line.ifcType === 'IfcAnnotation' || line.ifcType === 'IfcGridAxis'; if (!isAnnotationLike) return true; const wx = line.worldX; const wy = line.worldY; const wz = line.worldZ; if (wx === undefined || wy === undefined || wz === undefined) return true; const signedDist = wx * cullNormal[0] + wy * cullNormal[1] + wz * cullNormal[2] - cullDistance; if (sectionPlane.flipped) { return signedDist >= 0 && signedDist <= ANNOTATION_VIEW_DEPTH; } return signedDist <= 0 && signedDist >= -ANNOTATION_VIEW_DEPTH; }); // Only include symbolic lines for entities that are ACTUALLY being cut // This filters out symbols from other floors/levels not intersected by the section plane const relevantSymbolicLines = annotationCulled.filter(line => line.entityId !== undefined && cutEntityIds.has(line.entityId) ); // Get the set of entities that have both symbols AND are being cut const entitiesWithRelevantSymbols = new Set(); for (const line of relevantSymbolicLines) { if (line.entityId !== undefined) { entitiesWithRelevantSymbols.add(line.entityId); } } // Align symbolic geometry with section cut geometry using bounding box matching // Plan representations often have different local origins than Body representations // So we compute per-entity transforms to align Plan bbox center with section cut bbox center // Build per-entity bounding boxes for section cut const sectionCutBounds = new Map(); const updateBounds = (entityId: number, x: number, y: number) => { const bounds = sectionCutBounds.get(entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; bounds.minX = Math.min(bounds.minX, x); bounds.minY = Math.min(bounds.minY, y); bounds.maxX = Math.max(bounds.maxX, x); bounds.maxY = Math.max(bounds.maxY, y); sectionCutBounds.set(entityId, bounds); }; for (const line of result.lines) { if (line.entityId === undefined) continue; updateBounds(line.entityId, line.line.start.x, line.line.start.y); updateBounds(line.entityId, line.line.end.x, line.line.end.y); } // Include cut polygon vertices in bounds computation for (const poly of result.cutPolygons ?? []) { const entityId = (poly as { entityId?: number }).entityId; if (entityId === undefined) continue; for (const pt of poly.polygon.outer) { updateBounds(entityId, pt.x, pt.y); } for (const hole of poly.polygon.holes) { for (const pt of hole) { updateBounds(entityId, pt.x, pt.y); } } } // Build per-entity bounding boxes for symbolic const symbolicBounds = new Map(); for (const line of relevantSymbolicLines) { if (line.entityId === undefined) continue; const bounds = symbolicBounds.get(line.entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; bounds.minX = Math.min(bounds.minX, line.line.start.x, line.line.end.x); bounds.minY = Math.min(bounds.minY, line.line.start.y, line.line.end.y); bounds.maxX = Math.max(bounds.maxX, line.line.start.x, line.line.end.x); bounds.maxY = Math.max(bounds.maxY, line.line.start.y, line.line.end.y); symbolicBounds.set(line.entityId, bounds); } // Compute per-entity alignment transforms (center-to-center offset) const alignmentOffsets = new Map(); for (const entityId of entitiesWithRelevantSymbols) { const scBounds = sectionCutBounds.get(entityId); const symBounds = symbolicBounds.get(entityId); if (scBounds && symBounds) { const scCenterX = (scBounds.minX + scBounds.maxX) / 2; const scCenterY = (scBounds.minY + scBounds.maxY) / 2; const symCenterX = (symBounds.minX + symBounds.maxX) / 2; const symCenterY = (symBounds.minY + symBounds.maxY) / 2; alignmentOffsets.set(entityId, { dx: scCenterX - symCenterX, dy: scCenterY - symCenterY, }); } } // Apply alignment offsets to symbolic lines const alignedSymbolicLines = relevantSymbolicLines.map(line => { const offset = line.entityId !== undefined ? alignmentOffsets.get(line.entityId) : undefined; if (offset) { return { ...line, line: { start: { x: line.line.start.x + offset.dx, y: line.line.start.y + offset.dy }, end: { x: line.line.end.x + offset.dx, y: line.line.end.y + offset.dy }, }, }; } return line; }); // Filter out section cut lines for entities that have relevant symbolic representations const filteredLines = result.lines.filter((line: DrawingLine) => line.entityId === undefined || !entitiesWithRelevantSymbols.has(line.entityId) ); // Also filter cut polygons for entities with relevant symbols const filteredCutPolygons = result.cutPolygons?.filter((poly: { entityId?: number }) => poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId) ) ?? []; // Combine filtered section cuts with aligned symbolic lines const combinedLines = [...filteredLines, ...alignedSymbolicLines]; // Recalculate bounds with combined lines and polygons let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const line of combinedLines) { minX = Math.min(minX, line.line.start.x, line.line.end.x); minY = Math.min(minY, line.line.start.y, line.line.end.y); maxX = Math.max(maxX, line.line.start.x, line.line.end.x); maxY = Math.max(maxY, line.line.start.y, line.line.end.y); } // Include polygon vertices in bounds for (const poly of filteredCutPolygons) { for (const pt of poly.polygon.outer) { minX = Math.min(minX, pt.x); minY = Math.min(minY, pt.y); maxX = Math.max(maxX, pt.x); maxY = Math.max(maxY, pt.y); } for (const hole of poly.polygon.holes) { for (const pt of hole) { minX = Math.min(minX, pt.x); minY = Math.min(minY, pt.y); maxX = Math.max(maxX, pt.x); maxY = Math.max(maxY, pt.y); } } } // Create hybrid drawing const hybridDrawing: Drawing2D = { ...result, lines: combinedLines, cutPolygons: filteredCutPolygons, bounds: { min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y }, max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y }, }, stats: { ...result.stats, cutLineCount: combinedLines.length, }, }; setDrawing(hybridDrawing); } else { setDrawing(result); } // Always set status to ready (whether initial generation or regeneration) setDrawingStatus('ready'); isRegeneratingRef.current = false; } catch (error) { console.error('Drawing generation failed:', error); setDrawingError(error instanceof Error ? error.message : 'Generation failed'); } finally { // Always cleanup generator to prevent resource leaks generator?.dispose(); } }, [ geometryResult, ifcDataStore, sectionPlane, displayOptions, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, models, setDrawing, setDrawingStatus, setDrawingProgress, setDrawingError, ]); // Track panel visibility and geometry for detecting changes const prevPanelVisibleRef = useRef(false); const prevOverlayEnabledRef = useRef(false); const prevMeshCountRef = useRef(0); // Auto-generate when panel opens (or 3D overlay is enabled) and no drawing exists // Also regenerate when geometry changes significantly (e.g., models hidden/shown) useEffect(() => { const wasVisible = prevPanelVisibleRef.current; const wasOverlayEnabled = prevOverlayEnabledRef.current; const prevMeshCount = prevMeshCountRef.current; const currentMeshCount = geometryResult?.meshes?.length ?? 0; const hasGeometry = currentMeshCount > 0; // Track panel visibility separately from overlay const panelJustOpened = panelVisible && !wasVisible; const overlayJustEnabled = displayOptions.show3DOverlay && !wasOverlayEnabled; const isNowActive = panelVisible || displayOptions.show3DOverlay; const geometryChanged = currentMeshCount !== prevMeshCount; // Always update refs prevPanelVisibleRef.current = panelVisible; prevOverlayEnabledRef.current = displayOptions.show3DOverlay; prevMeshCountRef.current = currentMeshCount; if (isNowActive) { if (!hasGeometry) { // No geometry available - clear the drawing if (drawing) { setDrawing(null); setDrawingStatus('idle'); } } else if (panelJustOpened || overlayJustEnabled || !drawing || geometryChanged) { // Generate if: // 1. Panel just opened, OR // 2. Overlay just enabled, OR // 3. No drawing exists, OR // 4. Geometry changed significantly (models hidden/shown) generateDrawing(); } } }, [panelVisible, displayOptions.show3DOverlay, drawing, geometryResult, generateDrawing, setDrawing, setDrawingStatus]); // Auto-regenerate when section plane changes // Strategy: INSTANT - no debounce, but prevent overlapping computations // The generation time itself acts as natural batching for fast slider movements // // For face-picked custom planes (issue #243), `customKey` collapses the // plane's normal+distance into a string we can compare cheaply — without // it dragging the gizmo wouldn't trigger regeneration because the // cardinal axis/position/flipped triple stays the same. const customKey = (sp: { custom?: { normal: [number, number, number]; distance: number } }) => sp.custom ? `${sp.custom.normal.join(',')}|${sp.custom.distance}` : ''; const sectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped, customKey: customKey(sectionPlane), }); const isGeneratingRef = useRef(false); const latestSectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped, customKey: customKey(sectionPlane), }); const [isRegenerating, setIsRegenerating] = useState(false); // Stable regenerate function that handles overlapping calls const doRegenerate = useCallback(async () => { if (isGeneratingRef.current) { // Already generating - the latest position is already tracked in latestSectionRef // When current generation finishes, it will check if another is needed return; } isGeneratingRef.current = true; setIsRegenerating(true); // Capture position at start of generation const targetSection = { ...latestSectionRef.current }; try { await generateDrawing(true); } finally { isGeneratingRef.current = false; setIsRegenerating(false); // Check if section changed while we were generating const current = latestSectionRef.current; if ( current.axis !== targetSection.axis || current.position !== targetSection.position || current.flipped !== targetSection.flipped || current.customKey !== targetSection.customKey ) { // Position changed during generation - regenerate immediately with latest // Use microtask to avoid blocking queueMicrotask(() => doRegenerate()); } } }, [generateDrawing]); const customKeyValue = customKey(sectionPlane); useEffect(() => { // Always update latest section ref (even if generating) latestSectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped, customKey: customKeyValue, }; // Check if section plane actually changed from last processed const prev = sectionRef.current; if ( prev.axis === sectionPlane.axis && prev.position === sectionPlane.position && prev.flipped === sectionPlane.flipped && prev.customKey === customKeyValue ) { return; } // Update processed ref sectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped, customKey: customKeyValue, }; // If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) { // Start immediately - no debounce // doRegenerate handles preventing overlaps and will auto-regenerate with latest when done doRegenerate(); } }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, customKeyValue, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]); return { generateDrawing, doRegenerate, isRegenerating, }; } export default useDrawingGeneration;