@@ -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;
+ }
+}