/* 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/. */ /** * Export Dialog for GLB (binary glTF) export. * * v1 surface: model picker (single-model only — merged export deferred), * colour source (Rendering vs Shading), visible-only filter, include * metadata toggle. Other knobs Dion flagged (PBR reflectance, embed- * transparency, default material picker, coordinate origin, apply * mutations) are intentionally absent here and tracked separately. */ import { useState, useCallback, useMemo, useEffect } from 'react'; import { Download, AlertCircle, Check, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Alert, AlertDescription, AlertTitle, } from '@/components/ui/alert'; import { useViewerStore } from '@/store'; import { posthog } from '@/lib/analytics'; import { toast } from '@/components/ui/toast'; import { type MeshData } from '@ifc-lite/geometry'; import { exportGlbFromGeometry } from '@/lib/export/glb'; import { withInstancedMeshes } from '../../utils/instancedExport.js'; type ColorSource = 'rendering' | 'shading'; /** * Translate the viewer's `typeVisibility` toggles into the set of IFC class * names the GLB exporter should drop on a visible-only export. Mirrors the * gating in `basketVisibleSet.ts` and `ViewportContainer.tsx` so the export * matches what the user sees in the viewport. */ function buildHiddenIfcTypes( typeVisibility: { spaces: boolean; spatialZones: boolean; openings: boolean; virtualElements: boolean; site: boolean }, ): Set { const out = new Set(); if (!typeVisibility.spaces) out.add('IfcSpace'); if (!typeVisibility.spatialZones) out.add('IfcSpatialZone'); if (!typeVisibility.openings) out.add('IfcOpeningElement'); if (!typeVisibility.virtualElements) out.add('IfcVirtualElement'); if (!typeVisibility.site) out.add('IfcSite'); return out; } interface GLBExportDialogProps { trigger?: React.ReactNode; } export function GLBExportDialog({ trigger }: GLBExportDialogProps) { const models = useViewerStore((s) => s.models); const hiddenEntities = useViewerStore((s) => s.hiddenEntities); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel); const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel); // Class-level visibility (IfcSpace / IfcOpeningElement / IfcSite) — these // are off by default and live OUTSIDE the per-entity hidden set, so a // visible-only export that only checks `hiddenEntities` would still ship // openings the user never rendered (issue surfaced on the Revit door // fixture where IfcOpeningElement #2438 leaked through). const typeVisibility = useViewerStore((s) => s.typeVisibility); // Legacy single-model fallback so this dialog works before any // FederatedModel is registered (the common case for v1 users). Only // the geometryResult is needed — GLB export doesn't read the parsed // STEP store. const legacyGeometryResult = useViewerStore((s) => s.geometryResult); const [open, setOpen] = useState(false); const [selectedModelId, setSelectedModelId] = useState(''); const [colorSource, setColorSource] = useState('rendering'); const [visibleOnly, setVisibleOnly] = useState(false); const [includeMetadata, setIncludeMetadata] = useState(true); const [isExporting, setIsExporting] = useState(false); const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null); // Model list: federated models first, falling back to the legacy single // model when nothing is registered. Mirrors ExportDialog. const modelList = useMemo(() => { const list = Array.from(models.values()).map((m) => ({ id: m.id, name: m.name, geometryResult: m.geometryResult, })); if (list.length === 0 && legacyGeometryResult) { list.push({ id: '__legacy__', name: 'Current Model', geometryResult: legacyGeometryResult, }); } return list; }, [models, legacyGeometryResult]); // Default to the first model in the list whenever the menu opens. useEffect(() => { if (modelList.length > 0 && !selectedModelId) { setSelectedModelId(modelList[0].id); } }, [modelList, selectedModelId]); const selectedModel = useMemo(() => { if (selectedModelId === '__legacy__' && legacyGeometryResult) { return { id: '__legacy__', name: 'Current Model', geometryResult: legacyGeometryResult, }; } return modelList.find((m) => m.id === selectedModelId); }, [modelList, selectedModelId, legacyGeometryResult]); /** * Build the hidden / isolation sets in **global** ID space. * * `MeshData.expressId` carries the federated global ID (`local + * idOffset`, see `store/types.ts:365`), and the legacy / global store * sets (`hiddenEntities`, `isolatedEntities`) are also global — * `basketVisibleSet.ts:405-412` is the canonical reference for which * set lives in which space. Only the per-model `*ByModel` Maps store * raw local IDs, so those need an offset added before they can match * a mesh expressId. This is the opposite shape from the STEP exporter * (which works in local entity space), so don't reuse ExportDialog's * helpers here. */ const getGlobalHiddenIds = useCallback((modelId: string): Set => { if (modelId === '__legacy__') return hiddenEntities; const model = models.get(modelId); if (!model) return new Set(); const offset = model.idOffset ?? 0; const out = new Set(); // Global IDs from the legacy / global store — already global, just // restrict to this model's range so we don't carry over hidden IDs // that belong to sibling federated models. for (const globalId of hiddenEntities) { const localId = globalId - offset; if (localId > 0 && localId <= model.maxExpressId) { out.add(globalId); } } // Per-model entries are LOCAL IDs — convert to global. const modelHidden = hiddenEntitiesByModel.get(modelId); if (modelHidden) { for (const localId of modelHidden) { out.add(localId + offset); } } return out; }, [models, hiddenEntities, hiddenEntitiesByModel]); const getGlobalIsolatedIds = useCallback((modelId: string): Set | null => { if (modelId === '__legacy__') return isolatedEntities; const model = models.get(modelId); if (!model) return null; const offset = model.idOffset ?? 0; const out = new Set(); if (isolatedEntities) { for (const globalId of isolatedEntities) { const localId = globalId - offset; if (localId > 0 && localId <= model.maxExpressId) { out.add(globalId); } } } const modelIsolated = isolatedEntitiesByModel.get(modelId); if (modelIsolated) { for (const localId of modelIsolated) { out.add(localId + offset); } } return out.size > 0 ? out : null; }, [models, isolatedEntities, isolatedEntitiesByModel]); const handleExport = useCallback(async () => { if (!selectedModel?.geometryResult) return; setIsExporting(true); setExportResult(null); try { // Assemble the GLB in Rust over the meshes the viewer already holds (no // re-meshing). Visibility + colour-source selection is applied here because // the Rust path emits exactly the meshes it is handed — this mirrors the // previous GLTFExporter `isMeshVisible` / `pickColor` semantics. // // Fold in GPU-instanced occurrences (absent from geometryResult.meshes — they // live in shards) for the primary model so the GLB isn't missing repeated // geometry; the same visibility/colour filter below applies to them. // Instancing is the PRIMARY model only (idOffset 0). Detect that by offset, // not by the `__legacy__` id — a federated primary also has idOffset 0 but // carries a real model id, and would otherwise lose its instanced // occurrences from the export. Mirrors ExportDialog.tsx. (#1238 review) const federatedModel = models.get(selectedModelId); const idOffset = federatedModel?.idOffset ?? 0; const exportGeometry = withInstancedMeshes( selectedModel.geometryResult, idOffset === 0, ); const globalHidden = visibleOnly ? getGlobalHiddenIds(selectedModelId) : undefined; const globalIsolated = visibleOnly ? getGlobalIsolatedIds(selectedModelId) : undefined; const hiddenIfcTypes = visibleOnly ? buildHiddenIfcTypes(typeVisibility) : undefined; const hasIsolation = !!globalIsolated && globalIsolated.size > 0; const meshes = (exportGeometry.meshes as MeshData[]) .filter((m) => { if (!visibleOnly) return true; if (hiddenIfcTypes && m.ifcType && hiddenIfcTypes.has(m.ifcType)) return false; if (hasIsolation && !globalIsolated!.has(m.expressId)) return false; if (globalHidden && globalHidden.has(m.expressId)) return false; return true; }) .map((m) => colorSource === 'shading' && m.shadingColor ? ({ ...m, color: m.shadingColor } as MeshData) : m, ); const glb = await exportGlbFromGeometry(exportGeometry, { meshes, includeMetadata }); const blob = new Blob([new Uint8Array(glb)], { type: 'model/gltf-binary' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const baseName = selectedModel.name.replace(/\.[^.]+$/, ''); const suffix = visibleOnly ? '_visible' : ''; a.download = `${baseName}${suffix}.glb`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); const msg = `Exported GLB (${(blob.size / 1024).toFixed(0)} KB)`; setExportResult({ success: true, message: msg }); toast.success(msg); posthog.capture('export_completed', { format: 'glb', visible_only: visibleOnly, include_metadata: includeMetadata, color_source: colorSource, size_kb: Math.round(blob.size / 1024), }); } catch (err) { console.error('Export failed:', err); const errMsg = `GLB export failed: ${err instanceof Error ? err.message : 'Unknown error'}`; setExportResult({ success: false, message: errMsg }); toast.error(errMsg); } finally { setIsExporting(false); } }, [ selectedModel, selectedModelId, includeMetadata, colorSource, visibleOnly, typeVisibility, getGlobalHiddenIds, getGlobalIsolatedIds, ]); return ( {trigger || ( )} Export GLB File Export the 3D model as binary glTF for use in other viewers and renderers
{/* Model selector — only shown when multiple are loaded */} {modelList.length > 1 && (
)} {/* Colour source */}

{colorSource === 'rendering' ? 'Uses IfcSurfaceStyleRendering.DiffuseColour when authored, otherwise SurfaceColour. Matches most IFC viewers.' : 'Uses the base SurfaceColour. Falls back to the rendering colour when no distinct DiffuseColour was authored.'}

{/* Output format indicator */}
glTF Binary .glb
{/* Visible only */}

Skip entities currently hidden or outside the isolation set

{/* Include metadata */}

Embed expressId / modelIndex on each node and totals on the asset

{exportResult && ( {exportResult.success ? ( ) : ( )} {exportResult.success ? 'Success' : 'Error'} {exportResult.message} )}
); }