diff --git a/components/classification-panel.tsx b/components/classification-panel.tsx index 9747e38..e45f713 100644 --- a/components/classification-panel.tsx +++ b/components/classification-panel.tsx @@ -146,7 +146,7 @@ export function ClassificationPanel() { const { t } = useTranslation(); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isBsddDialogOpen, setIsBsddDialogOpen] = useState(false); - const [bsddFeatureSeen, setBsddFeatureSeen] = useState(false); + const [bsddFeatureSeen, setBsddFeatureSeen] = useState(true); // Default to true to prevent initial layout shift const [newClassification, setNewClassification] = useState({ code: "", name: "", @@ -417,10 +417,13 @@ export function ClassificationPanel() { }, [exportableModels, selectedModelIdForExport]); useEffect(() => { - // Feature highlight: show pulse on 3-dot menu until user opens it once + // Feature highlight: show subtle indicator until user opens it once try { const seen = localStorage.getItem("bsddFeatureSeen"); - setBsddFeatureSeen(seen === "true"); + // Only update state if it's actually false, to prevent unnecessary re-renders + if (seen !== "true") { + setBsddFeatureSeen(false); + } } catch (e) { // ignore } @@ -1153,13 +1156,15 @@ export function ClassificationPanel() { {" "} {/* Subtle look */} + {/* BSDD feature indicator with nice animations */} {!bsddFeatureSeen && ( <> - {/* Glow ping dot */} + {/* Glow ping dot with smooth animation */} - {/* Floating label */} - + + {/* Floating label with bounce animation - positioned to not affect layout */} + bSDD search @@ -1725,58 +1730,28 @@ export function ClassificationPanel() { )} {sortedClassificationEntries.length === 0 ? ( -
- {searchQuery ? ( -

+

+ {/* Always render both states with consistent structure to prevent layout shifts */} +
+

{t("classifications.noSearchResults")}

- ) : ( - <> -
- -
-

- {t("noClassificationsAdded")} -

-

- {t("addClassification")} -

-
- {isLoadingUniclass ? ( - - ) : errorLoadingUniclass ? ( - - ) : defaultUniclassPr.length > 0 ? ( - - ) : ( - - )} - {isLoadingEBKPH ? ( - - ) : errorLoadingEBKPH ? ( - - ) : defaultEBKPH.length > 0 ? ( - - ) : ( - - )} -
- - )} +
+ +
+
+ +
+

+ {t("noClassificationsAdded")} +

+

+ {t("addClassification")} +

+

+ Use the + button above to create your first classification +

+
) : (
diff --git a/components/ifc-model.tsx b/components/ifc-model.tsx index 041f169..88b68db 100644 --- a/components/ifc-model.tsx +++ b/components/ifc-model.tsx @@ -8,6 +8,8 @@ import { LoadedModelData, SelectedElementInfo, } from "@/context/ifc-context"; +import { IFCElementExtractor } from "@/services/ifc-element-extractor"; +import { PropertyIndex } from "@/services/property-index"; import * as THREE from "three"; import { IfcAPI, @@ -914,6 +916,19 @@ export function IFCModel({ modelData, outlineLayer }: IFCModelProps) { `IFCModel (${modelData.id}): Spatial structure extraction failed or empty.` ); + // Build property index for fast property-based selections + try { + console.log(`IFCModel (${modelData.id}): Building property index for fast selections...`); + const allElements = IFCElementExtractor.getAllElements(ifcApi, newIfcModelID); + await PropertyIndex.buildIndex(ifcApi, newIfcModelID, allElements, (progress) => { + console.log(`IFCModel (${modelData.id}): Property index building progress: ${progress}%`); + }); + console.log(`IFCModel (${modelData.id}): Property index built successfully`); + } catch (indexError) { + console.warn(`IFCModel (${modelData.id}): Failed to build property index:`, indexError); + // Continue without index - selections will use fallback method + } + const allTypesResult = ifcApi.GetIfcEntityList(newIfcModelID); const allTypesArray: number[] = Array.isArray(allTypesResult) ? allTypesResult diff --git a/components/ifc-viewer.tsx b/components/ifc-viewer.tsx index 1399b77..8f9dc5c 100644 --- a/components/ifc-viewer.tsx +++ b/components/ifc-viewer.tsx @@ -938,6 +938,7 @@ function ViewerContent() { addIFCModel, clearSelection, ruleProgress, + selectionProgress, showAllClassificationColors, toggleShowAllClassificationColors, isolateUnclassified, @@ -2182,6 +2183,28 @@ function ViewerContent() {
)} + + {/* Selection Processing Progress */} + {selectionProgress?.active && ( +
+
+ +
+
+ )} @@ -2440,7 +2463,7 @@ function FileUpload({ isAdding = false }: FileUploadProps) {

{t('ifcModelViewer')}

-

+

{t('uploadIFCFile')}

{t('ifcModelViewer')}

-

+

{t('uploadIFCFile')}

string; + selectPath?: string[]; } const PropertyRow: React.FC = ({ @@ -211,11 +212,17 @@ const PropertyRow: React.FC = ({ icon, copyValue, t, + selectPath, }) => { - const { ifcApi } = useIFCContext(); + const { ifcApi, selectElementsByProperty } = useIFCContext(); const handleCopy = () => { if (copyValue !== undefined) navigator.clipboard.writeText(copyValue); }; + const handleSelect = () => { + if (selectPath) { + selectElementsByProperty(selectPath, propValue); + } + }; return (
@@ -233,6 +240,20 @@ const PropertyRow: React.FC = ({ } > {renderPropertyValue(propValue, propKey, ifcApi, t)} + {selectPath && ( + + + + + + + {t?.('selectAllMatching') || 'Select all'} + + + + )} {copyValue !== undefined && (
-
+
{ifcType || 'Unknown'} + {ifcType && ( + + + + + + {t('selectAllMatching')} + + + )}
@@ -704,6 +744,7 @@ export function ModelInfo() { propValue={value} icon={getPropertyIcon(key)} t={t} + selectPath={['attributes', key]} /> ))} @@ -786,6 +827,7 @@ export function ModelInfo() { propValue={propValue} icon={getPropertyIcon(propName)} t={t} + selectPath={['propertySets', psetName, propName]} /> ), ) @@ -821,6 +863,7 @@ export function ModelInfo() { propValue={propValue} icon={getPropertyIcon(propName)} t={t} + selectPath={['typeSets', psetName, propName]} /> ))} diff --git a/context/ifc-context.tsx b/context/ifc-context.tsx index 38fb8ee..3f13869 100644 --- a/context/ifc-context.tsx +++ b/context/ifc-context.tsx @@ -15,12 +15,17 @@ import { Properties } from "web-ifc"; // Ensure Properties is imported import { getAllElementProperties, ParsedElementProperties } from "@/services/ifc-properties"; import { IFCElementExtractor } from "@/services/ifc-element-extractor"; import { PropertyCache } from "@/services/property-cache"; +import { PropertyIndex } from "@/services/property-index"; import { parseRulesFromExcel } from "@/services/rule-import-service"; import { exportRulesToExcel } from "@/services/rule-export-service"; import { exportClassificationsToExcel } from "@/services/classification-export-service"; import { parseClassificationsFromExcel } from "@/services/classification-import-service"; import { getBufferedConsole, type ConsoleUpdate } from "@/services/buffered-console-service"; +function deepEqual(a: any, b: any) { + return JSON.stringify(a) === JSON.stringify(b); +} + // Define interfaces for progress tracking export interface RuleProgress { active: boolean; @@ -132,6 +137,7 @@ interface IFCContextType { selectElements: (selection: SelectedElementInfo[]) => void; toggleElementSelection: (element: SelectedElementInfo, additive: boolean) => void; clearSelection: () => void; + selectElementsByProperty: (path: string[], value: any) => Promise; toggleClassificationHighlight: (classificationCode: string) => void; setElementProperties: (properties: any | null) => void; setAvailableCategoriesForModel: ( @@ -195,6 +201,12 @@ interface IFCContextType { // Rule application progress (for UX feedback) ruleProgress: RuleProgress; + selectionProgress: { + active: boolean; + percent: number; + status: string; + matchCount: number; + }; } @@ -248,6 +260,19 @@ export function IFCContextProvider({ children }: { children: ReactNode }) { matchCount: 0, }); + // Selection progress state (for UI feedback during large selections) + const [selectionProgress, setSelectionProgress] = useState<{ + active: boolean; + percent: number; + status: string; + matchCount: number; + }>({ + active: false, + percent: 0, + status: "", + matchCount: 0, + }); + // Buffered console for performance const bufferedConsole = useRef(getBufferedConsole()); @@ -1577,6 +1602,147 @@ export function IFCContextProvider({ children }: { children: ReactNode }) { setElementPropertiesInternal, ]); + const selectElementsByProperty = useCallback( + async (path: string[], value: any) => { + if (!ifcApiInternal) return; + + console.log(`[selectElementsByProperty] Searching for ${path.join('.')} = ${value}`); + const matches: SelectedElementInfo[] = []; + + // Check if this will be a large selection that needs progress indication + let totalElements = 0; + for (const model of loadedModels) { + if (model.modelID == null) continue; + if (PropertyIndex.hasIndex(ifcApiInternal, model.modelID)) { + // For indexed models, we can estimate the total quickly + const allIndexed = PropertyIndex.getAllIndexedExpressIDs(ifcApiInternal, model.modelID); + totalElements += allIndexed.length; + } else { + // For non-indexed models, use element extractor + const elements = IFCElementExtractor.getAllElements(ifcApiInternal, model.modelID); + totalElements += elements.length; + } + } + + // Show progress for large selections (>1000 elements) + const showProgress = totalElements > 1000; + if (showProgress) { + setSelectionProgress({ + active: true, + percent: 0, + status: `Searching ${totalElements.toLocaleString()} elements for ${path.join('.')} = ${value}`, + matchCount: 0, + }); + } + + for (const model of loadedModels) { + if (model.modelID == null) continue; + + // Try using the property index first (much faster) + if (PropertyIndex.hasIndex(ifcApiInternal, model.modelID)) { + console.log(`[selectElementsByProperty] Using property index for model ${model.modelID}`); + const indexedExpressIDs = PropertyIndex.findElementsByProperty(ifcApiInternal, model.modelID, path, value); + + for (const expressID of indexedExpressIDs) { + matches.push({ modelID: model.modelID, expressID }); + } + + if (showProgress) { + setSelectionProgress(prev => ({ + ...prev, + matchCount: matches.length, + })); + } + + console.log(`[selectElementsByProperty] Found ${indexedExpressIDs.length} matches using index`); + continue; + } + + // Fallback to the old method if no index exists + console.log(`[selectElementsByProperty] No index available for model ${model.modelID}, using fallback method`); + const elements = IFCElementExtractor.getAllElements(ifcApiInternal, model.modelID); + + // For large models, batch process to improve performance + const batchSize = 500; + for (let i = 0; i < elements.length; i += batchSize) { + const batch = elements.slice(i, i + batchSize); + const expressIDs = batch.map(el => el.expressID); + + try { + // Use batch property fetching for better performance + const batchProperties = await PropertyCache.getBatchProperties(ifcApiInternal, model.modelID, expressIDs); + + for (const [expressID, props] of Array.from(batchProperties)) { + let current: any = props; + for (const part of path) { + if (current == null) break; + current = current[part as keyof typeof current]; + } + if (current !== undefined && deepEqual(current, value)) { + matches.push({ modelID: model.modelID, expressID }); + } + } + } catch (error) { + console.warn(`[selectElementsByProperty] Batch processing failed for model ${model.modelID}:`, error); + // Fall back to individual processing for this batch + for (const el of batch) { + try { + const props = await PropertyCache.getProperties(ifcApiInternal, model.modelID, el.expressID); + let current: any = props; + for (const part of path) { + if (current == null) break; + current = current[part as keyof typeof current]; + } + if (current !== undefined && deepEqual(current, value)) { + matches.push({ modelID: model.modelID, expressID: el.expressID }); + } + } catch { + // Ignore individual property fetch errors + } + } + } + + // Update progress for large selections + if (showProgress) { + const percent = Math.round((i + batch.length) / elements.length * 100); + setSelectionProgress(prev => ({ + ...prev, + percent, + matchCount: matches.length, + })); + } + } + } + + console.log(`[selectElementsByProperty] Total matches found: ${matches.length}`); + + // Clear progress and select elements + if (showProgress) { + setSelectionProgress({ + active: false, + percent: 100, + status: `Found ${matches.length} matching elements`, + matchCount: matches.length, + }); + + // Clear progress after a short delay + setTimeout(() => { + setSelectionProgress({ + active: false, + percent: 0, + status: "", + matchCount: 0, + }); + }, 2000); + } + + if (matches.length > 0) { + selectElements(matches); + } + }, + [ifcApiInternal, loadedModels, selectElements], + ); + const toggleElementSelection = useCallback( (element: SelectedElementInfo, additive: boolean) => { setSelectedElements((prev) => { @@ -2298,6 +2464,7 @@ export function IFCContextProvider({ children }: { children: ReactNode }) { selectElements, toggleElementSelection, clearSelection, + selectElementsByProperty, toggleClassificationHighlight, setElementProperties, setAvailableCategoriesForModel, @@ -2340,6 +2507,7 @@ export function IFCContextProvider({ children }: { children: ReactNode }) { naturalIfcClassNames, getNaturalIfcClassName, ruleProgress, + selectionProgress, }} > {children} diff --git a/public/locales/de/common.json b/public/locales/de/common.json index a2a9fdc..81e1230 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -21,6 +21,7 @@ "no": "Nein", "notSet": "Nicht gesetzt", "emptyList": "Leere Liste", + "selectAllMatching": "Alle Elemente mit gleichem Wert auswählen", "listCount": "Liste ({{count}} Elemente)", "complexData": "Komplexe Daten", "loadingSchemaPreview": "Schema-Vorschau wird geladen...", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5ff457c..0448f4f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -21,6 +21,7 @@ "no": "No", "notSet": "Not set", "emptyList": "Empty list", + "selectAllMatching": "Select all elements with same value", "listCount": "List ({{count}} items)", "complexData": "Complex data", "loadingSchemaPreview": "Loading schema preview...", diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index c326051..fe4bf57 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -21,6 +21,7 @@ "no": "Non", "notSet": "Non défini", "emptyList": "Liste vide", + "selectAllMatching": "Sélectionner tous les éléments avec la même valeur", "listCount": "Liste ({{count}} éléments)", "complexData": "Données complexes", "loadingSchemaPreview": "Chargement de l'aperçu du schéma...", diff --git a/public/locales/it/common.json b/public/locales/it/common.json index b8c1fd9..52d427b 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -21,6 +21,7 @@ "no": "No", "notSet": "Non impostato", "emptyList": "Lista vuota", + "selectAllMatching": "Seleziona tutti gli elementi con lo stesso valore", "listCount": "Lista ({{count}} elementi)", "complexData": "Dati complessi", "loadingSchemaPreview": "Caricamento anteprima schema...", diff --git a/services/property-index.ts b/services/property-index.ts new file mode 100644 index 0000000..b8e2a5e --- /dev/null +++ b/services/property-index.ts @@ -0,0 +1,276 @@ +import { IfcAPI } from "web-ifc"; +import { PropertyCache } from "./property-cache"; + +export interface PropertyIndexEntry { + expressID: number; + ifcType?: string; + name?: string; + globalId?: string; + description?: string; + objectType?: string; + tag?: string; + predefinedType?: string; +} + +/** + * High-performance property index for IFC elements + * Provides O(1) lookups for common properties instead of O(n) searches + */ +export class PropertyIndex { + private static index = new Map(); + private static apiIds = new WeakMap(); + private static nextApiId = 1; + + /** + * Get stable ID for IfcAPI instance + */ + private static getApiId(ifcApi: IfcAPI): number { + if (!this.apiIds.has(ifcApi)) { + this.apiIds.set(ifcApi, this.nextApiId++); + } + return this.apiIds.get(ifcApi)!; + } + + /** + * Get cache key for model + */ + private static getCacheKey(modelID: number, ifcApi?: IfcAPI): string { + const apiPrefix = ifcApi ? `${this.getApiId(ifcApi)}-` : ''; + return `${apiPrefix}${modelID}`; + } + + /** + * Build property index for a model + * This should be called during model loading to pre-warm the index + */ + static async buildIndex( + ifcApi: IfcAPI, + modelID: number, + elements: Array<{ expressID: number; type: string; typeCode: number }>, + onProgress?: (progress: number) => void + ): Promise { + const cacheKey = this.getCacheKey(modelID, ifcApi); + + // Check if index already exists + if (this.index.has(cacheKey)) { + console.log(`[PropertyIndex] Index already exists for model ${modelID}`); + return; + } + + console.log(`[PropertyIndex] Building index for ${elements.length} elements in model ${modelID}`); + + const indexEntries: PropertyIndexEntry[] = []; + const batchSize = 100; // Process in smaller batches to avoid blocking + + for (let i = 0; i < elements.length; i += batchSize) { + const batch = elements.slice(i, i + batchSize); + const expressIDs = batch.map(el => el.expressID); + + try { + // Use batch property fetching for better performance + const batchProperties = await PropertyCache.getBatchProperties(ifcApi, modelID, expressIDs); + + for (const [expressID, properties] of batchProperties) { + const entry: PropertyIndexEntry = { expressID }; + + // Index the most commonly searched properties + if (properties.ifcType) entry.ifcType = properties.ifcType; + if (properties.attributes?.Name?.value) entry.name = String(properties.attributes.Name.value); + if (properties.attributes?.GlobalId?.value) entry.globalId = String(properties.attributes.GlobalId.value); + if (properties.attributes?.Description?.value) entry.description = String(properties.attributes.Description.value); + if (properties.attributes?.ObjectType?.value) entry.objectType = String(properties.attributes.ObjectType.value); + if (properties.attributes?.Tag?.value) entry.tag = String(properties.attributes.Tag.value); + if (properties.attributes?.PredefinedType?.value) entry.predefinedType = String(properties.attributes.PredefinedType.value); + + indexEntries.push(entry); + } + } catch (error) { + console.warn(`[PropertyIndex] Failed to process batch ${Math.floor(i / batchSize)}:`, error); + // Continue with other batches even if one fails + } + + // Progress reporting + if (onProgress) { + const progress = Math.round(((i + batch.length) / elements.length) * 100); + onProgress(progress); + } + } + + // Store the index + this.index.set(cacheKey, indexEntries); + console.log(`[PropertyIndex] Built index with ${indexEntries.length} entries for model ${modelID}`); + } + + /** + * Find elements by property path and value using the index + */ + static findElementsByProperty( + ifcApi: IfcAPI, + modelID: number, + propertyPath: string[], + value: any + ): number[] { + const cacheKey = this.getCacheKey(modelID, ifcApi); + const indexEntries = this.index.get(cacheKey); + + if (!indexEntries) { + console.warn(`[PropertyIndex] No index found for model ${modelID}, falling back to cache`); + return []; + } + + // Handle common property paths efficiently + if (propertyPath.length === 1) { + const propertyName = propertyPath[0]; + + switch (propertyName) { + case 'ifcType': + return indexEntries + .filter(entry => entry.ifcType === value) + .map(entry => entry.expressID); + + case 'attributes.Name': + case 'name': + return indexEntries + .filter(entry => entry.name === value) + .map(entry => entry.expressID); + + case 'attributes.GlobalId': + case 'globalId': + return indexEntries + .filter(entry => entry.globalId === value) + .map(entry => entry.expressID); + + case 'attributes.Description': + case 'description': + return indexEntries + .filter(entry => entry.description === value) + .map(entry => entry.expressID); + + case 'attributes.ObjectType': + case 'objectType': + return indexEntries + .filter(entry => entry.objectType === value) + .map(entry => entry.expressID); + + case 'attributes.Tag': + case 'tag': + return indexEntries + .filter(entry => entry.tag === value) + .map(entry => entry.expressID); + + case 'attributes.PredefinedType': + case 'predefinedType': + return indexEntries + .filter(entry => entry.predefinedType === value) + .map(entry => entry.expressID); + } + } + + // For IFC class searches, we can do early filtering by type + if (propertyPath.length === 1 && propertyPath[0] === 'ifcType') { + console.log(`[PropertyIndex] Using optimized IFC type filtering for ${value}`); + return indexEntries + .filter(entry => entry.ifcType === value) + .map(entry => entry.expressID); + } + + // For other property paths, we need to fall back to cache + console.log(`[PropertyIndex] Property path ${propertyPath.join('.')} not indexed, using cache fallback`); + return []; + } + + /** + * Get elements by IFC type (optimized early filtering) + */ + static getElementsByType( + ifcApi: IfcAPI, + modelID: number, + ifcType: string + ): number[] { + const cacheKey = this.getCacheKey(modelID, ifcApi); + const indexEntries = this.index.get(cacheKey); + + if (!indexEntries) { + return []; + } + + return indexEntries + .filter(entry => entry.ifcType === ifcType) + .map(entry => entry.expressID); + } + + /** + * Get all indexed express IDs for a model + */ + static getAllIndexedExpressIDs(ifcApi: IfcAPI, modelID: number): number[] { + const cacheKey = this.getCacheKey(modelID, ifcApi); + const indexEntries = this.index.get(cacheKey); + + if (!indexEntries) { + return []; + } + + return indexEntries.map(entry => entry.expressID); + } + + /** + * Check if index exists for a model + */ + static hasIndex(ifcApi: IfcAPI, modelID: number): boolean { + const cacheKey = this.getCacheKey(modelID, ifcApi); + return this.index.has(cacheKey); + } + + /** + * Clear index for specific model or all models + */ + static clearIndex(modelID?: number, ifcApi?: IfcAPI): void { + if (modelID !== undefined || ifcApi !== undefined) { + const keysToDelete: string[] = []; + const apiId = ifcApi ? this.getApiId(ifcApi) : null; + + for (const key of Array.from(this.index.keys())) { + let shouldDelete = false; + + if (ifcApi !== undefined && modelID !== undefined) { + shouldDelete = key === `${apiId}-${modelID}`; + } else if (modelID !== undefined) { + shouldDelete = key.includes(`-${modelID}`) || key === modelID.toString(); + } else if (ifcApi !== undefined) { + shouldDelete = key.startsWith(`${apiId}-`); + } + + if (shouldDelete) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.index.delete(key); + } + } else { + this.index.clear(); + } + } + + /** + * Get index statistics + */ + static getIndexStats() { + const stats = { + indexedModels: this.index.size, + totalIndexedElements: 0, + modelDetails: [] as Array<{ modelID: string; elementCount: number }> + }; + + for (const [cacheKey, entries] of this.index.entries()) { + stats.totalIndexedElements += entries.length; + stats.modelDetails.push({ + modelID: cacheKey, + elementCount: entries.length + }); + } + + return stats; + } +}