Compare commits

...

23 Commits

Author SHA1 Message Date
oguzhankoral c85fcdddd9 chore(revit-mapper): css 2025-08-14 14:40:43 +03:00
Björn b3ecc582d3 fix: jokes i was being dumb 2025-08-14 12:51:41 +02:00
Björn 5e47af7641 fix: auto import not working? 2025-08-14 12:43:39 +02:00
Björn 008e6fde55 refactor: group conditional buttons in mapper 2025-08-14 12:22:28 +02:00
Björn c575203580 refactor: remove redundant Props interfaces in mapper components 2025-08-14 12:16:14 +02:00
Björn 3b0c6dbcec fix: redundant div 2025-08-14 12:11:41 +02:00
Björn 314dd69357 fix: pr comments 2025-08-14 12:09:03 +02:00
Björn e88d0b75d5 refactor: hardcoded list now in dui 2025-08-14 11:54:36 +02:00
Björn Steinhagen 16abccbb2e Merge pull request #46 from specklesystems/bjorn/cnx-2297-rename-button-to-assing-revit-categories
chore: rename button to Assign Revit Categories
2025-08-14 00:01:18 +08:00
Björn 7ca5fcb625 chore: rename button to Assign Revit Categories 2025-08-13 17:58:25 +02:00
Björn d33d32ad30 Merge branch 'bjorn/cnx-2273-select-all-button-to-get-all-mapped-objects' into bjorn/cnx-2192-create-a-mapperlayer-filter 2025-08-13 13:44:16 +02:00
Björn 47ba084767 refactor: components to make mapper more maintainable 2025-08-13 13:43:35 +02:00
Björn Steinhagen 311e3f70ec Merge pull request #45 from specklesystems/bjorn/cnx-2273-select-all-button-to-get-all-mapped-objects
feat(dui): add select all mapped objects and auto-refresh improvements
2025-08-13 05:16:25 +08:00
Björn 1c05477c72 fix: event handling 2025-08-12 23:06:07 +02:00
Björn 7b29add6c7 feat: added Select All button and updated event handling 2025-08-12 23:05:54 +02:00
Björn 1ebbed1441 fix: formatting 2025-08-12 19:52:59 +02:00
Björn ae83bca72b fix: refresh layer list on doc switch 2025-08-12 19:33:35 +02:00
Björn d1d76f8b9a fix: multi instead of base 2025-08-12 19:30:58 +02:00
Björn 65062b8f89 feat: hierarchical layer object highlighting and simple mappings mgmt 2025-08-12 18:25:00 +02:00
Björn ece7211243 feat: layer dropdown 2025-08-12 14:11:16 +02:00
Björn 022687d28c feat: adds mode toggle 2025-08-12 13:42:57 +02:00
Björn e4f991ebce fix: add missing layer mock methods to IRevitMapperBinding 2025-08-12 10:58:59 +02:00
Björn 2548062453 feat: update mapper binding interface for layer support and renamed methods 2025-08-12 10:47:35 +02:00
9 changed files with 793 additions and 152 deletions
+1 -1
View File
@@ -46,7 +46,7 @@
]"
@click="$router.push('/revit-mapper')"
>
Revit integration
Assign Revit Categories
</button>
</MenuItem>
<MenuItem
+80
View File
@@ -0,0 +1,80 @@
<template>
<div class="px-2">
<p class="h5">Layer Selection</p>
<div class="space-y-2 my-2">
<!-- Multi-select layer dropdown -->
<FormSelectMulti
:model-value="selectedLayers"
name="layerSelection"
label="Select layers"
class="w-full"
fixed-height
size="sm"
:items="layerOptions"
:allow-unset="false"
by="id"
search
:search-placeholder="'Search layers...'"
:filter-predicate="layerSearchFilterPredicate"
mount-menu-on-body
@update:model-value="(value) => $emit('update:selectedLayers', value as LayerOption[])"
>
<template #something-selected="{ value }">
<span class="text-primary text-xs">
{{ `${value.length} layer${value.length !== 1 ? 's' : ''} selected` }}
</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item.name }}</span>
</template>
</FormSelectMulti>
<!-- Layer selection summary -->
<div
v-if="selectedLayers.length === 0"
class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs"
>
<div class="text-foreground-2">
No layers selected, choose layers from the dropdown above!
</div>
</div>
<div v-else class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div>
Selected {{ selectedLayers.length }} layer{{
selectedLayers.length !== 1 ? 's' : ''
}}:
{{ selectedLayers.map((l) => l.name).join(', ') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface LayerOption {
id: string
name: string
[key: string]: unknown
}
defineProps<{
selectedLayers: LayerOption[]
layerOptions: LayerOption[]
}>()
defineEmits<{
'update:selectedLayers': [layers: LayerOption[]]
}>()
// Search predicate for layer dropdown
const layerSearchFilterPredicate = (
item: LayerOption | string | number | Record<string, unknown>,
query: string
): boolean => {
if (typeof item === 'object' && item !== null && 'name' in item) {
const layerItem = item as LayerOption
return layerItem.name.toLowerCase().includes(query.toLowerCase())
}
return false
}
</script>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="py-1 px-2 bg-foundation border rounded-lg">
<div class="flex justify-between items-center">
<div class="text-xs font-medium grow">{{ categoryLabel }}</div>
<div class="flex space-x-1">
<div class="flex justify-center items-center text-xs text-foreground-2 mr-1">
{{ countText }}
</div>
<FormButton
v-if="tooltipText"
v-tippy="tooltipText"
size="sm"
color="outline"
:icon-left="CursorArrowRaysIcon"
hide-text
@click="$emit('select')"
/>
<FormButton
v-else
size="sm"
color="outline"
:icon-left="CursorArrowRaysIcon"
hide-text
@click="$emit('select')"
/>
<FormButton class="!px-1.5" size="sm" color="outline" @click="$emit('clear')">
<TrashIcon class="w-3 h-3" />
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CursorArrowRaysIcon, TrashIcon } from '@heroicons/vue/24/outline'
defineProps<{
categoryLabel: string
countText: string
tooltipText?: string
}>()
defineEmits<{
select: []
clear: []
}>()
</script>
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div v-if="!hasSelection">
No objects selected, go ahead and select some from your model!
</div>
<div v-else>{{ selectionSummary }}</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
hasSelection: boolean
selectionSummary: string
}>()
</script>
+75 -22
View File
@@ -6,25 +6,29 @@ import type {
export const IRevitMapperBindingKey = 'revitMapperBinding'
export interface IRevitMapperBinding extends IBinding<IMapperBindingEvents> {
// Gets list of available Revit categories for the UI dropdown
getAvailableCategories: () => Promise<Category[]>
// Gets list of defined layers in doc
getAvailableLayers: () => Promise<LayerOption[]>
// Assigns selected objects to a specific Revit category
assignToCategory: (objectIds: string[], categoryValue: string) => Promise<void>
// Object methods
assignObjectsToCategory: (objectIds: string[], categoryValue: string) => Promise<void>
clearObjectsCategoryAssignment: (objectIds: string[]) => Promise<void>
clearAllObjectsCategoryAssignments: () => Promise<void>
getCurrentObjectsMappings: () => Promise<CategoryMapping[]>
// Removes category assignments from specific objects
clearCategoryAssignment: (objectIds: string[]) => Promise<void>
// Removes all category assignments in the doc
clearAllCategoryAssignments: () => Promise<void>
// Gets all current mappings to show in the UI table
getCurrentMappings: () => Promise<CategoryMapping[]>
// Layer methods
assignLayerToCategory: (layerIds: string[], categoryValue: string) => Promise<void>
clearLayerCategoryAssignment: (layerIds: string[]) => Promise<void>
clearAllLayerCategoryAssignments: () => Promise<void>
getCurrentLayerMappings: () => Promise<LayerCategoryMapping[]>
getEffectiveObjectsForLayerMapping: (
layerIds: string[],
categoryValue: string
) => Promise<string[]>
}
export interface IMapperBindingEvents extends IBindingSharedEvents {
// Notify when mappings change
mappingsChanged: (mappings: CategoryMapping[]) => void
layersChanged: (layers: LayerOption[]) => void
}
export interface Category {
@@ -39,39 +43,88 @@ export interface CategoryMapping {
objectCount: number
}
export interface LayerCategoryMapping {
categoryValue: string
categoryLabel: string
layerIds: string[]
layerNames: string[]
layerCount: number
}
export interface LayerOption {
id: string
name: string
}
// Mock implementation for dev/testing
export class MockedMapperBinding implements IRevitMapperBinding {
private mockMappings: CategoryMapping[] = []
public assignToCategory(objectIds: string[], categoryValue: string): Promise<void> {
public assignObjectsToCategory(
objectIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning objects to category', { objectIds, categoryValue })
return Promise.resolve()
}
public getAvailableCategories(): Promise<Category[]> {
public getAvailableLayers(): Promise<LayerOption[]> {
return Promise.resolve([
{ value: 'OST_Walls', label: 'Walls' },
{ value: 'OST_Floors', label: 'Floors' },
{ value: 'OST_Ceilings', label: 'Ceilings' },
{ value: 'OST_Columns', label: 'Columns' }
{ id: 'layer1', name: 'Ground Floor' },
{ id: 'layer2', name: 'Ground Floor/Walls' },
{ id: 'layer3', name: 'Ground Floor/Walls/Interior' },
{ id: 'layer4', name: 'Second Floor' }
])
}
public clearCategoryAssignment(objectIds: string[]): Promise<void> {
public clearObjectsCategoryAssignment(objectIds: string[]): Promise<void> {
console.log('Mock: Clearing category assignment', { objectIds })
return Promise.resolve()
}
public clearAllCategoryAssignments(): Promise<void> {
public clearAllObjectsCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all assignments')
this.mockMappings = []
return Promise.resolve()
}
public getCurrentMappings(): Promise<CategoryMapping[]> {
public getCurrentObjectsMappings(): Promise<CategoryMapping[]> {
return Promise.resolve(this.mockMappings)
}
public assignLayerToCategory(
layerIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning layers to category', { layerIds, categoryValue })
return Promise.resolve()
}
public clearLayerCategoryAssignment(layerIds: string[]): Promise<void> {
console.log('Mock: Clearing layer category assignment', { layerIds })
return Promise.resolve()
}
public clearAllLayerCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all layer assignments')
return Promise.resolve()
}
public getCurrentLayerMappings(): Promise<LayerCategoryMapping[]> {
return Promise.resolve([])
}
public getEffectiveObjectsForLayerMapping(
layerIds: string[],
categoryValue: string
): Promise<string[]> {
console.log('Mock: Getting effective objects for layer mapping', {
layerIds,
categoryValue
})
return Promise.resolve(['obj1', 'obj2', 'obj3'])
}
public showDevTools(): Promise<void> {
console.log('Braaaaa, no way!')
return Promise.resolve()
+114
View File
@@ -0,0 +1,114 @@
import type { CategoryOption } from './types'
/**
* Hardcoded Revit BuiltInCategories for the Interop Lite mapper.
*/
export const REVIT_CATEGORIES: readonly CategoryOption[] = [
// INFRASTRUCTURE
{ value: 'OST_BridgeAbutments', label: 'Bridge Abutments' },
{ value: 'OST_BridgeFraming', label: 'Bridge Framing' },
{ value: 'OST_BridgeBearings', label: 'Bridge Bearings' },
{ value: 'OST_BridgeCables', label: 'Bridge Cables' },
{ value: 'OST_BridgeDecks', label: 'Bridge Decks' },
{ value: 'OST_ExpansionJoints', label: 'Expansion Joints' },
{ value: 'OST_BridgePiers', label: 'Bridge Piers' },
{ value: 'OST_VibrationManagement', label: 'Vibration Management' },
// ELECTRICAL
{ value: 'OST_AudioVisualDevices', label: 'Audio Visual Devices' },
{ value: 'OST_CableTray', label: 'Cable Tray' },
{ value: 'OST_CableTrayFitting', label: 'Cable Tray Fittings' },
{ value: 'OST_CommunicationDevices', label: 'Communication Devices' },
{ value: 'OST_Conduit', label: 'Conduits' },
{ value: 'OST_ConduitFitting', label: 'Conduit Fittings' },
{ value: 'OST_DataDevices', label: 'Data Devices' },
{ value: 'OST_ElectricalEquipment', label: 'Electrical Equipment' },
{ value: 'OST_ElectricalFixtures', label: 'Electrical Fixtures' },
{ value: 'OST_FireAlarmDevices', label: 'Fire Alarm Devices' },
{ value: 'OST_LightingDevices', label: 'Lighting Devices' },
{ value: 'OST_LightingFixtures', label: 'Lighting Fixtures' },
{ value: 'OST_NurseCallDevices', label: 'Nurse Call Devices' },
{ value: 'OST_SecurityDevices', label: 'Security Devices' },
{ value: 'OST_TelephoneDevices', label: 'Telephone Devices' },
// ARCHITECTURAL
{ value: 'OST_Casework', label: 'Casework' },
{ value: 'OST_Ceilings', label: 'Ceilings' },
{ value: 'OST_Columns', label: 'Columns' },
{ value: 'OST_CurtainWallMullions', label: 'Curtain Wall Mullions' },
{ value: 'OST_CurtainWallPanels', label: 'Curtain Panels' },
// OST_Curtain_Systems excluded as part of CNX-2299
{ value: 'OST_Doors', label: 'Doors' },
{ value: 'OST_Entourage', label: 'Entourage' },
{ value: 'OST_Floors', label: 'Floors' },
{ value: 'OST_FoodServiceEquipment', label: 'Food Service Equipment' },
{ value: 'OST_Furniture', label: 'Furniture' },
{ value: 'OST_FurnitureSystems', label: 'Furniture Systems' },
{ value: 'OST_Hardscape', label: 'Hardscape' },
{ value: 'OST_Parking', label: 'Parking' },
{ value: 'OST_Planting', label: 'Planting' },
{ value: 'OST_Railings', label: 'Railings' },
{ value: 'OST_Ramps', label: 'Ramps' },
{ value: 'OST_Roads', label: 'Roads' },
{ value: 'OST_Roofs', label: 'Roofs' },
{ value: 'OST_Site', label: 'Site' },
{ value: 'OST_SpecialityEquipment', label: 'Speciality Equipment' },
{ value: 'OST_Stairs', label: 'Stairs' },
{ value: 'OST_Topography', label: 'Topography' },
{ value: 'OST_Toposolid', label: 'Toposolid' },
{ value: 'OST_Walls', label: 'Walls' },
{ value: 'OST_Windows', label: 'Windows' },
// STRUCTURAL
// OST_StructuralColumns excluded as part of CNX-2299
{ value: 'OST_StructuralConnections', label: 'Structural Connections' },
{ value: 'OST_StructuralFoundation', label: 'Structural Foundations' },
{ value: 'OST_StructuralFraming', label: 'Structural Framing' },
{ value: 'OST_StructuralFramingSystem', label: 'Structural Beam Systems' },
{ value: 'OST_StructuralFabricAreas', label: 'Structural Fabric Areas' },
{ value: 'OST_Rebar', label: 'Rebar' },
{ value: 'OST_StructuralStiffener', label: 'Structural Stiffeners' },
{ value: 'OST_StructuralTendons', label: 'Structural Tendons' },
{ value: 'OST_StructuralTruss', label: 'Structural Trusses' },
// MECHANICAL
{ value: 'OST_DuctAccessory', label: 'Duct Accessories' },
{ value: 'OST_DuctCurves', label: 'Ducts' },
{ value: 'OST_DuctFitting', label: 'Duct Fittings' },
{ value: 'OST_DuctSystem', label: 'Duct Systems' },
{ value: 'OST_MechanicalEquipment', label: 'Mechanical Equipment' },
{ value: 'OST_PlumbingEquipment', label: 'Plumbing Equipment' },
{ value: 'OST_PlumbingFixtures', label: 'Plumbing Fixtures' },
// PIPING
{ value: 'OST_PipeAccessory', label: 'Pipe Accessories' },
{ value: 'OST_PipeCurves', label: 'Pipes' },
{ value: 'OST_PipeFitting', label: 'Pipe Fittings' },
{ value: 'OST_Sprinklers', label: 'Sprinklers' },
// GENERAL/MULTI-DISCIPLINE
{ value: 'OST_FireProtection', label: 'Fire Protection' },
{ value: 'OST_GenericModel', label: 'Generic Models' },
{ value: 'OST_Lines', label: 'Lines' },
{ value: 'OST_Mass', label: 'Mass' },
{ value: 'OST_MedicalEquipment', label: 'Medical Equipment' },
{ value: 'OST_Parts', label: 'Parts' },
{ value: 'OST_Signage', label: 'Signage' },
{ value: 'OST_TemporaryStructure', label: 'Temporary Structures' },
{ value: 'OST_VerticalCirculation', label: 'Vertical Circulation' }
] as const
/**
* Get available categories sorted alphabetically by label.
*/
export function getAvailableCategories(): CategoryOption[] {
return [...REVIT_CATEGORIES].sort((a, b) => a.label.localeCompare(b.label))
}
/**
* Gets the human-readable label for a category value.
*/
export function getCategoryLabel(categoryValue: string): string {
const category = REVIT_CATEGORIES.find((c) => c.value === categoryValue)
return category?.label ?? categoryValue
}
+4
View File
@@ -0,0 +1,4 @@
export interface CategoryOption {
value: string // e.g. "OST_Walls"
label: string // e.g. "Walls"
}
+2 -2
View File
@@ -115,7 +115,7 @@
</span>
</FormButton> -->
</div>
<!--Revit Integration button (only if mapper binding exists)-->
<!--Assign Revit Categories button (only if mapper binding exists)-->
<div v-if="app.$revitMapperBinding" class="mt-2">
<hr class="border-outline-2 mb-2" />
<FormButton
@@ -125,7 +125,7 @@
full-width
@click="$router.push('/revit-mapper')"
>
Revit Integration
Assign Revit Categories
</FormButton>
</div>
</LayoutPanel>
+454 -127
View File
@@ -1,31 +1,57 @@
<template>
<div class="flex flex-col space-y-2">
<div class="px-2 mt-2">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="my-2">
<div class="px-2 space-y-1">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="my-1">
Home
</FormButton>
<hr />
</div>
<!-- Step 1: Selection Mode (currently only support by selection) -->
<!-- Step 1: Mapping Mode Selection -->
<div class="px-2">
<p class="h5">Selection</p>
<p class="h5">Mapping Mode</p>
<div class="space-y-2 my-2">
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div v-if="(selectionInfo?.selectedObjectIds?.length || 0) === 0">
No objects selected, go ahead and select some from your model!
</div>
<div v-else>{{ selectionInfo?.summary }}</div>
<FormSelectBase
:model-value="selectedMappingMode"
name="mappingMode"
label="Mapping mode"
class="w-full"
fixed-height
size="sm"
:items="mappingModeOptions"
:allow-unset="false"
mount-menu-on-body
@update:model-value="(value) => handleModeChange(value as string)"
>
<template #something-selected="{ value }">
<span class="text-primary text-xs">{{ value }}</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item }}</span>
</template>
</FormSelectBase>
<!-- Mode-specific content -->
<div v-if="selectedMappingMode === 'Selection'">
<MapperSelectionMapper
:has-selection="(selectionInfo?.selectedObjectIds?.length || 0) > 0"
:selection-summary="selectionInfo?.summary || ''"
/>
</div>
<MapperLayerMapper
v-if="selectedMappingMode === 'Layer'"
v-model:selected-layers="selectedLayers"
:layer-options="layerOptions"
/>
</div>
</div>
<!-- Step 2: Category Selection (only shown when objects are selected) -->
<div v-if="hasObjectsSelected" class="px-2">
<!-- Step 2: Category Selection -->
<div v-if="hasTargetsSelected" class="px-2">
<p class="h5">Target Category</p>
<div class="space-y-2 my-2">
<!-- Flex layout with dropdown and apply button side by side -->
<div class="flex space-x-2">
<div class="flex space-x-2 items-center">
<div class="flex-1">
<FormSelectBase
key="label"
@@ -44,7 +70,7 @@
>
<template #something-selected="{ value }">
<span class="text-primary text-xs">
{{ Array.isArray(value) ? value[0]?.label : value.label }}
{{ Array.isArray(value) ? value[0]?.label : value?.label }}
</span>
</template>
<template #option="{ item }">
@@ -53,70 +79,111 @@
</FormSelectBase>
</div>
<!-- Apply button - same height as dropdown -->
<!-- Apply button -->
<FormButton
color="primary"
size="sm"
class="h-8"
:disabled="!selectedCategory"
@click="assignToCategory()"
>
Apply Mapping
Apply
</FormButton>
</div>
</div>
</div>
<hr v-if="hasObjectsSelected" />
<hr />
<!-- Step 3: Mappings Summary Table -->
<div v-if="mappings.length > 0" class="px-2">
<p class="h5">Current Mappings</p>
<!-- Step 3: Mappings Summary Tables -->
<div
v-if="currentMappings.length > 0 || currentLayerMappings.length > 0"
class="px-2"
>
<p class="h5">
{{ `Current Mappings (${currentMappings.length > 0 ? 'Object' : 'Layer'})` }}
</p>
<!-- Only mapping items get space-y -->
<div class="space-y-1 my-2">
<div
v-for="mapping in mappings"
:key="mapping.categoryValue"
class="py-1 px-2 bg-foundation border rounded-lg"
>
<div class="flex justify-between items-center">
<div class="text-sm font-medium grow">{{ mapping.categoryLabel }}</div>
<div class="flex space-x-1">
<div
class="flex justify-center items-center text-xs text-foreground-2 mr-1"
>
{{ mapping.objectCount }} object{{
mapping.objectCount !== 1 ? 's' : ''
}}
</div>
<FormButton
size="sm"
color="outline"
:icon-left="CursorArrowRaysIcon"
hide-text
@click="selectMappedObjects(mapping)"
/>
<FormButton
class="!px-1.5"
size="sm"
color="outline"
@click="clearMapping(mapping)"
>
<TrashIcon class="w-3 h-3" />
</FormButton>
</div>
</div>
<!-- Object Mappings Section -->
<div v-if="currentMappings.length > 0" class="my-2">
<div class="space-y-1">
<MapperMappedElementItem
v-for="mapping in currentMappings"
:key="mapping.categoryValue"
:category-label="mapping.categoryLabel"
:count-text="`${mapping.objectCount} object${
mapping.objectCount !== 1 ? 's' : ''
}`"
@select="selectMappedObjects(mapping)"
@clear="clearMapping(mapping)"
/>
</div>
</div>
<!-- Clear all button separated, with custom margin -->
<div class="flex justify-end">
<FormButton size="sm" color="danger" @click="clearAllMappings()">
Clear All
</FormButton>
<!-- Layer Mappings Section -->
<div v-if="currentLayerMappings.length > 0" class="my-2">
<div class="space-y-1">
<MapperMappedElementItem
v-for="layerMapping in currentLayerMappings"
:key="layerMapping.categoryValue"
:category-label="layerMapping.categoryLabel"
:count-text="`${layerMapping.layerCount} layer${
layerMapping.layerCount !== 1 ? 's' : ''
}`"
:tooltip-text="`Layers: ${layerMapping.layerNames.join(', ')}`"
@select="selectMappedLayers(layerMapping)"
@clear="clearLayerMapping(layerMapping)"
/>
</div>
</div>
<!-- Clear All and Select All buttons -->
<div class="flex justify-end space-x-2">
<!-- Selection mode buttons -->
<div
v-if="selectedMappingMode === 'Selection' && currentMappings.length > 0"
class="flex space-x-2"
>
<FormButton size="sm" color="outline" @click="selectAllMappedObjects()">
Select All
</FormButton>
<FormButton size="sm" color="danger" @click="clearAllMappings()">
Clear All Objects
</FormButton>
</div>
<!-- Layer mode buttons -->
<div
v-else-if="selectedMappingMode === 'Layer' && currentLayerMappings.length > 0"
class="flex space-x-2"
>
<FormButton size="sm" color="outline" @click="selectAllMappedLayers()">
Select All
</FormButton>
<FormButton size="sm" color="danger" @click="clearAllLayerMappings()">
Clear All Layers
</FormButton>
</div>
</div>
<!-- Mode Confirmation Dialog -->
<CommonDialog
v-model:open="showModeConfirmDialog"
title="Switch Mapping Mode"
fullscreen="none"
>
<div class="text-sm text-foreground">
{{ conflictMessage }}
</div>
<div class="mt-4 flex justify-end space-x-2">
<FormButton size="sm" color="outline" @click="cancelModeChange()">
Cancel
</FormButton>
<FormButton size="sm" color="danger" @click="confirmModeChange()">
Clear & Switch
</FormButton>
</div>
</CommonDialog>
</div>
</div>
</template>
@@ -124,89 +191,349 @@
<script setup lang="ts">
// === IMPORTS ===
import { storeToRefs } from 'pinia'
import { ArrowLeftIcon, CursorArrowRaysIcon } from '@heroicons/vue/20/solid'
import { TrashIcon } from '@heroicons/vue/24/outline'
import { ArrowLeftIcon } from '@heroicons/vue/20/solid'
import { useSelectionStore } from '~/store/selection'
import type {
Category,
CategoryMapping
CategoryMapping,
LayerCategoryMapping
} from '~/lib/bindings/definitions/IRevitMapperBinding'
// === STORE INTEGRATION ===
const selectionStore = useSelectionStore()
const { selectionInfo, hasBinding: hasSelectionBinding } = storeToRefs(selectionStore)
const { $revitMapperBinding, $baseBinding } = useNuxtApp()
// Import categories
import { getAvailableCategories, getCategoryLabel } from '~/lib/mapper/revit-categories'
// === REACTIVE STATE ===
const categoryOptions = ref<Category[]>([])
// === STORES ===
const selectionStore = useSelectionStore()
const { selectionInfo } = storeToRefs(selectionStore)
// === STATE ===
const selectedMappingMode = ref<string>('Selection')
const mappingModeOptions = ['Selection', 'Layer']
const selectedCategory = ref<Category | undefined>(undefined)
const categoryOptions = ref<Category[]>([])
const mappings = ref<CategoryMapping[]>([])
// Layer-specific state
const selectedLayers = ref<LayerOption[]>([])
const layerOptions = ref<LayerOption[]>([])
const layerMappings = ref<LayerCategoryMapping[]>([])
// Mode switching state
const showModeConfirmDialog = ref(false)
const pendingMode = ref<string>('')
const conflictMessage = ref('')
// === TYPES ===
interface LayerOption {
id: string
name: string
}
// === COMPUTED ===
const hasObjectsSelected = computed(
() => (selectionInfo.value?.selectedObjectIds?.length || 0) > 0
)
const searchFilterPredicate = (
item: { value: string; label: string },
search: string
) => item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase())
// === WATCHERS ===
watch(
hasSelectionBinding,
(newValue) => {
if (newValue) {
selectionStore.refreshSelectionFromHostApp()
}
},
{ immediate: true }
)
// === INITIALIZATION ===
const loadCategories = async () => {
const categories = (await $revitMapperBinding?.getAvailableCategories()) || []
categoryOptions.value = categories
}
const refreshMappings = async () => {
const currentMappings = (await $revitMapperBinding?.getCurrentMappings()) || []
mappings.value = currentMappings
}
// === CATEGORY ASSIGNMENT ===
const assignToCategory = async () => {
if (!selectedCategory.value || !selectionInfo.value?.selectedObjectIds) {
return
const hasTargetsSelected = computed(() => {
if (selectedMappingMode.value === 'Selection') {
return (selectionInfo.value?.selectedObjectIds?.length || 0) > 0
} else if (selectedMappingMode.value === 'Layer') {
return selectedLayers.value.length > 0
}
const objectIds = selectionInfo.value.selectedObjectIds
await $revitMapperBinding?.assignToCategory(objectIds, selectedCategory.value.value)
await refreshMappings()
selectedCategory.value = undefined
}
// === CATEGORY CLEARING ===
const clearMapping = async (mapping: CategoryMapping) => {
await $revitMapperBinding?.clearCategoryAssignment(mapping.objectIds)
await refreshMappings()
}
const clearAllMappings = async () => {
await $revitMapperBinding?.clearAllCategoryAssignments()
await refreshMappings()
return false
})
// Show appropriate mappings based on current mode
const currentMappings = computed(() => {
return selectedMappingMode.value === 'Selection' ? mappings.value : []
})
const currentLayerMappings = computed(() => {
return selectedMappingMode.value === 'Layer' ? layerMappings.value : []
})
// === METHODS ===
const app = useNuxtApp()
const { $revitMapperBinding, $baseBinding } = app
// Search predicate for category dropdown
const searchFilterPredicate = (item: Category, query: string) => {
return item.label.toLowerCase().includes(query.toLowerCase())
}
// === OBJECT SELECTION ===
// Handle mode changes with conflict checking
const handleModeChange = (newMode: string) => {
// If switching to same mode, do nothing
if (newMode === selectedMappingMode.value) return
// Check for conflicts - ONLY show dialog if there are existing mappings
if (newMode === 'Layer' && mappings.value.length > 0) {
// Switching to Layer mode with existing object mappings
pendingMode.value = newMode
conflictMessage.value = `Switching to Layer mode will clear all current object mappings. Continue?`
showModeConfirmDialog.value = true
} else if (newMode === 'Selection' && layerMappings.value.length > 0) {
// Switching to Selection mode with existing layer mappings
pendingMode.value = newMode
conflictMessage.value = `Switching to Selection mode will clear all current layer mappings. Continue?`
showModeConfirmDialog.value = true
} else {
// No conflicts, switch directly (no existing mappings or switching to same mode)
selectedMappingMode.value = newMode
}
}
// Cancel mode change
const cancelModeChange = () => {
showModeConfirmDialog.value = false
pendingMode.value = ''
conflictMessage.value = ''
}
// Confirm mode change and clear conflicting mappings
const confirmModeChange = async () => {
try {
if (pendingMode.value === 'Layer') {
// Clear all object mappings before switching to Layer mode
await $revitMapperBinding?.clearAllObjectsCategoryAssignments()
} else if (pendingMode.value === 'Selection') {
// Clear all layer mappings before switching to Selection mode
await $revitMapperBinding?.clearAllLayerCategoryAssignments()
}
// Switch mode
selectedMappingMode.value = pendingMode.value
await refreshMappings()
// Close dialog
showModeConfirmDialog.value = false
pendingMode.value = ''
conflictMessage.value = ''
} catch (error) {
console.error('Failed to clear mappings during mode switch:', error)
}
}
// Assign selected objects/layers to the chosen category
const assignToCategory = async () => {
if (!selectedCategory.value || !hasTargetsSelected.value) return
try {
if (selectedMappingMode.value === 'Selection') {
await $revitMapperBinding?.assignObjectsToCategory(
selectionInfo.value?.selectedObjectIds || [],
selectedCategory.value.value
)
} else if (selectedMappingMode.value === 'Layer') {
await $revitMapperBinding?.assignLayerToCategory(
selectedLayers.value.map((layer) => layer.id),
selectedCategory.value.value
)
selectedLayers.value = []
}
selectedCategory.value = undefined
await refreshMappings()
} catch (error) {
console.error('Failed to assign to category:', error)
}
}
// Clear a specific object mapping
const clearMapping = async (mapping: CategoryMapping) => {
try {
await $revitMapperBinding?.clearObjectsCategoryAssignment(mapping.objectIds)
await refreshMappings()
} catch (error) {
console.error('Failed to clear mapping:', error)
}
}
// Clear all object mappings
const clearAllMappings = async () => {
try {
await $revitMapperBinding?.clearAllObjectsCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all mappings:', error)
}
}
// Clear a specific layer mapping
const clearLayerMapping = async (layerMapping: LayerCategoryMapping) => {
try {
await $revitMapperBinding?.clearLayerCategoryAssignment(layerMapping.layerIds)
await refreshMappings()
} catch (error) {
console.error('Failed to clear layer mapping:', error)
}
}
// Clear all layer mappings
const clearAllLayerMappings = async () => {
try {
await $revitMapperBinding?.clearAllLayerCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all layer mappings:', error)
}
}
// Select mapped objects in Rhino
const selectMappedObjects = async (mapping: CategoryMapping) => {
await $baseBinding?.highlightObjects(mapping.objectIds)
try {
await $baseBinding?.highlightObjects(mapping.objectIds)
} catch (error) {
console.error('Failed to highlight objects:', error)
}
}
// Select mapped layers (highlight objects on those layers)
const selectMappedLayers = async (layerMapping: LayerCategoryMapping) => {
try {
const effectiveObjectIds =
(await $revitMapperBinding?.getEffectiveObjectsForLayerMapping(
layerMapping.layerIds,
layerMapping.categoryValue
)) || []
if (effectiveObjectIds.length > 0) {
await $baseBinding?.highlightObjects(effectiveObjectIds)
}
} catch (error) {
console.error('Failed to highlight effective objects:', error)
}
}
// Select all mapped objects (Selection mode)
const selectAllMappedObjects = async () => {
try {
const allObjectIds = currentMappings.value.flatMap((mapping) => mapping.objectIds)
if (allObjectIds.length > 0) {
await $baseBinding?.highlightObjects(allObjectIds)
}
} catch (error) {
console.error('Failed to select all mapped objects:', error)
}
}
// Select all objects affected by layer mappings (Layer mode)
const selectAllMappedLayers = async () => {
try {
const allEffectiveObjectIds: string[] = []
// Get effective objects for each layer mapping
for (const layerMapping of currentLayerMappings.value) {
const effectiveObjectIds =
(await $revitMapperBinding?.getEffectiveObjectsForLayerMapping(
layerMapping.layerIds,
layerMapping.categoryValue
)) || []
allEffectiveObjectIds.push(...effectiveObjectIds)
}
// Remove duplicates and highlight
const uniqueObjectIds = [...new Set(allEffectiveObjectIds)]
if (uniqueObjectIds.length > 0) {
await $baseBinding?.highlightObjects(uniqueObjectIds)
}
} catch (error) {
console.error('Failed to select all layer-mapped objects:', error)
}
}
// Load available categories, layers, and current mappings
const loadData = async () => {
try {
const [categories, rawMappings, rawLayerMappings, layers] = await Promise.all([
getAvailableCategories() || [],
$revitMapperBinding?.getCurrentObjectsMappings() || [],
$revitMapperBinding?.getCurrentLayerMappings() || [],
loadAvailableLayers()
])
categoryOptions.value = categories
// Mappings need to be changed human-readable labels
mappings.value = rawMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerOptions.value = layers
} catch (error) {
console.error('Failed to load mapper data:', error)
}
}
// Refresh both object and layer mappings
const refreshMappings = async () => {
try {
const [rawMappings, rawLayerMappings] = await Promise.all([
$revitMapperBinding?.getCurrentObjectsMappings() || [],
$revitMapperBinding?.getCurrentLayerMappings() || []
])
// Transform to resolve labels
mappings.value = rawMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
} catch (error) {
console.error('Failed to refresh mappings:', error)
}
}
// Load available layers from Rhino document
const loadAvailableLayers = async (): Promise<LayerOption[]> => {
try {
// Call the backend method to get available layers
const layers = (await $revitMapperBinding?.getAvailableLayers()) || []
return layers
} catch (error) {
console.error('Failed to load layers:', error)
return []
}
}
// Refresh just the layer mappings
const refreshLayerMappings = async () => {
try {
const rawLayerMappings =
(await $revitMapperBinding?.getCurrentLayerMappings()) || []
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
} catch (error) {
console.error('Failed to refresh layer mappings:', error)
}
}
// === LIFECYCLE ===
onMounted(async () => {
$revitMapperBinding?.on('mappingsChanged', (updatedMappings: CategoryMapping[]) => {
mappings.value = updatedMappings
onMounted(() => {
loadData()
// Listen for mappings changes
$revitMapperBinding?.on('mappingsChanged', (newMappings: CategoryMapping[]) => {
mappings.value = newMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
refreshLayerMappings()
})
// Listen for layer list changes
$revitMapperBinding?.on('layersChanged', (newLayers: LayerOption[]) => {
layerOptions.value = newLayers
selectedLayers.value = []
})
await loadCategories()
await refreshMappings()
if (hasSelectionBinding.value) {
selectionStore.refreshSelectionFromHostApp()
}
})
</script>