Files
speckle-connectors-dui/pages/revit-mapper.vue
T
2025-09-05 16:22:44 +01:00

694 lines
22 KiB
Vue

<template>
<div class="flex flex-col space-y-2">
<div class="px-2 space-y-1">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="my-1">
Home
</FormButton>
<hr />
</div>
<!-- Step 1: Mapping Mode Selection -->
<div class="px-2">
<p class="h5">Assign by</p>
<div class="space-y-2 my-2">
<FormSelectBase
:model-value="selectedMappingMode"
name="mappingMode"
label="Assign by"
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 -->
<div v-if="hasTargetsSelected" class="px-2">
<p class="h5">Target Category</p>
<div class="space-y-2 my-2">
<div class="flex space-x-2 items-center">
<div class="flex-1">
<FormSelectBase
key="label"
v-model="revitMapperStore.selectedCategory"
name="categoryMapping"
:placeholder="dropdownPlaceholder"
label="Target Category"
fixed-height
size="sm"
search
:search-placeholder="''"
:filter-predicate="searchFilterPredicate"
:items="categoryOptions"
:allow-unset="false"
mount-menu-on-body
>
<template #something-selected>
<span class="text-primary text-xs">
{{ displayLabel }}
</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item.label }}</span>
</template>
</FormSelectBase>
</div>
<!-- Apply button -->
<FormButton
color="primary"
size="sm"
:disabled="!revitMapperStore.selectedCategory?.value"
@click="assignToCategory()"
>
Apply
</FormButton>
</div>
</div>
</div>
<hr />
<!-- Step 3: Mappings Summary Tables -->
<div
v-if="currentMappings.length > 0 || currentLayerMappings.length > 0"
class="px-2"
>
<p class="h5">
{{ `Assigned Categories (${currentMappings.length > 0 ? 'Object' : 'Layer'})` }}
</p>
<!-- 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>
<!-- 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 Category Assignment 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>
<script setup lang="ts">
// === IMPORTS ===
import { storeToRefs } from 'pinia'
import { ArrowLeftIcon } from '@heroicons/vue/20/solid'
import { useSelectionStore } from '~/store/selection'
import { useRevitMapper } from '~/store/revitMapper'
import type {
Category,
CategoryMapping,
LayerCategoryMapping
} from '~/lib/bindings/definitions/IRevitMapperBinding'
// Import categories
import { getAvailableCategories, getCategoryLabel } from '~/lib/mapper/revit-categories'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
// === STORES ===
const selectionStore = useSelectionStore()
const revitMapperStore = useRevitMapper()
const { selectionInfo } = storeToRefs(selectionStore)
const { trackEvent } = useMixpanel()
// === STATE ===
const selectedMappingMode = ref<string | undefined>(undefined)
const mappingModeOptions = ['Selection', 'Layer']
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
}
// === MAPPING CATEGORY STATE MGMT ===
const app = useNuxtApp()
const { $revitMapperBinding, $baseBinding } = app
// const categoryState = useRevitCategoryState(categoryOptions, $revitMapperBinding)
// === COMPUTED ===
const hasTargetsSelected = computed(() => {
if (selectedMappingMode.value === 'Selection') {
return (selectionInfo.value?.selectedObjectIds?.length || 0) > 0
} else if (selectedMappingMode.value === 'Layer') {
return selectedLayers.value.length > 0
}
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 : []
})
const dropdownPlaceholder = computed(() => {
if (revitMapperStore.categoryStatus) {
return revitMapperStore.categoryStatus?.isMultiple
? 'Multiple categories'
: 'Select a category'
}
return undefined
})
const displayLabel = computed(() => {
const multiple = revitMapperStore.categoryStatus?.isMultiple
return multiple
? 'Multiple categories'
: revitMapperStore.selectedCategory?.label || ''
})
// === METHODS ===
// Search predicate for category dropdown
const searchFilterPredicate = (item: Category, query: string) => {
return item.label.toLowerCase().includes(query.toLowerCase())
}
// 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 assignment mode will clear all current object category assignments. 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 assignment mode will clear all current layer category assignments. 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()
}
// Track the manual mode switch
trackEvent('DUI3 Action', {
name: 'Mapper Mode Changed',
mode: selectedMappingMode.value
})
// Switch mode
selectedMappingMode.value = pendingMode.value
await refreshMappings()
// Close dialog
showModeConfirmDialog.value = false
pendingMode.value = ''
conflictMessage.value = ''
} catch (error) {
console.error('Failed to clear category assignments during mode switch:', error)
}
}
// Assign selected objects/layers to the chosen category
const assignToCategory = async () => {
if (!revitMapperStore.selectedCategory?.value || !hasTargetsSelected.value) return
try {
let assignedCount = 0
const { selectedCategory } = storeToRefs(revitMapperStore)
const categoryValue = selectedCategory?.value?.value
if (selectedMappingMode.value === 'Selection' && categoryValue) {
const objectIds = selectionInfo.value?.selectedObjectIds || []
await $revitMapperBinding?.assignObjectsToCategory(objectIds, categoryValue)
assignedCount = objectIds.length
// Track the assignment
trackEvent('DUI3 Action', {
name: 'Mapper Assign Category',
category: categoryValue,
count: assignedCount,
mappingType: 'object'
})
} else if (selectedMappingMode.value === 'Layer' && categoryValue) {
const layerIds = selectedLayers.value.map((layer) => layer.id)
await $revitMapperBinding?.assignLayerToCategory(layerIds, categoryValue)
assignedCount = selectedLayers.value.length
// Track the assignment
trackEvent('DUI3 Action', {
name: 'Mapper Assign Category',
category: categoryValue,
count: assignedCount,
mappingType: 'layer'
})
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 category assignment:', error)
}
}
// Clear all object mappings
const clearAllMappings = async () => {
try {
await $revitMapperBinding?.clearAllObjectsCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all category assignments:', 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 assignment:', error)
}
}
// Clear all layer mappings
const clearAllLayerMappings = async () => {
try {
await $revitMapperBinding?.clearAllLayerCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all layer assignments:', error)
}
}
// Select mapped objects in Rhino
const selectMappedObjects = async (mapping: CategoryMapping) => {
try {
await $baseBinding?.highlightObjects(mapping.objectIds)
} catch (error) {
console.error('Failed to highlight objects:', error)
}
}
// Select mapped layers (highlight objects AND restore UI state)
const selectMappedLayers = async (layerMapping: LayerCategoryMapping) => {
try {
// 1. Highlight objects in Rhino
const effectiveObjectIds =
(await $revitMapperBinding?.getEffectiveObjectsForLayerMapping(
layerMapping.layerIds,
layerMapping.categoryValue
)) || []
if (effectiveObjectIds.length > 0) {
await $baseBinding?.highlightObjects(effectiveObjectIds)
}
// 2. Restore UI state - populate layer selection
const layersToSelect = layerOptions.value.filter((layer) =>
layerMapping.layerIds.includes(layer.id)
)
selectedLayers.value = layersToSelect
// 3. Pre-select category in dropdown
const categoryToSelect = categoryOptions.value.find(
(cat) => cat.value === layerMapping.categoryValue
)
const { selectedCategory, currentCategories } = storeToRefs(revitMapperStore)
selectedCategory.value = categoryToSelect
// 4. Update reactive state
currentCategories.value = [layerMapping.categoryValue]
} 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 objects with categories assigned:', 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 objects with categories assigned by layer:',
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
// Transform mappings to include 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
// IMPORTANT: Determine initial mapping mode based on existing mappings
// This preserves the user's last used mode and prevents mixed state scenarios
if (!selectedMappingMode.value) {
if (rawLayerMappings.length > 0 && rawMappings.length === 0) {
// Only layer mappings exist - user was in Layer mode
selectedMappingMode.value = 'Layer'
} else if (rawMappings.length > 0 && rawLayerMappings.length === 0) {
// Only object mappings exist - user was in Selection mode
selectedMappingMode.value = 'Selection'
} else if (rawLayerMappings.length > 0 && rawMappings.length > 0) {
// Mixed state detected - this shouldn't happen, but default to Selection
// and let the conflict handling take care of it
selectedMappingMode.value = 'Selection'
console.warn(
'Mixed assignment state detected - both object and layer assignments exist'
)
} else {
// No existing mappings - default to Selection mode
selectedMappingMode.value = 'Selection'
}
}
} catch (error) {
console.error('Failed to load categorizer data:', error)
// Fallback to Selection mode if loading fails
if (!selectedMappingMode.value) {
selectedMappingMode.value = 'Selection'
}
}
}
// Refresh both object and layer mappings
const refreshMappings = async () => {
try {
if (!$revitMapperBinding) {
console.warn('No Revit category assignment binding available')
return
}
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 category assignments:', 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 []
}
}
// === WATCHER ===
// Main watcher
watch(
() => ({
mode: selectedMappingMode.value,
objectIds: selectionInfo.value?.selectedObjectIds || [],
layerIds: selectedLayers.value.map((l) => l.id)
}),
async ({ mode, objectIds, layerIds }) => {
if (mode === 'Selection') {
await revitMapperStore.updateFromTargets(objectIds, false)
} else if (mode === 'Layer') {
// In Layer mode, we need to watch both manual layer selection AND object selection
// This keeps dropdowns clear when objects are deselected (like Selection mode)
// while still supporting manual layer selection
if (layerIds.length > 0) {
// User has manually selected layers in UI - use layer mode logic
await revitMapperStore.updateFromTargets(layerIds, true)
} else {
// No manual layer selection - use object mode logic (like Selection mode)
// This handles the case where selectMappedLayers populated the UI but objects were deselected
await revitMapperStore.updateFromTargets(objectIds, false)
}
}
},
{ immediate: true, deep: true }
)
// This handles clearing selectedLayers when objects are deselected in Layer mode
watch(
() => selectionInfo.value?.selectedObjectIds?.length || 0,
async (newCount, oldCount) => {
// Only act in Layer mode when selection count goes to 0 and we have selected layers
if (
selectedMappingMode.value === 'Layer' &&
newCount === 0 &&
oldCount > 0 &&
selectedLayers.value.length > 0
) {
// nextTick to avoid interfering with the main watcher? not nice :(
await nextTick()
selectedLayers.value = []
}
}
)
// === LIFECYCLE ===
onMounted(async () => {
await selectionStore.refreshSelectionFromHostApp()
await 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 = []
})
// Track mapper opened with the initial mode (after loadData determines it)
trackEvent('DUI3 Action', {
name: 'Mapper Opened',
mode: selectedMappingMode.value
})
})
// Refresh just 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 category assignments:', error)
}
}
</script>