feat(rhino): add revit mapper UI for category assignment (#41)

* feat: basic structure

* feat: categories

* feat: selection filter

* chore: mock categories

* feat: second iteration

* docs: comments

* feat: create mapper binding interface

* feat: register bindings

* feat: add Revit Integration button

conditionally based on the presence or absence of binding

* fix: tooltip

* fix: missing method and interface for `getAvailableCategories`

* fix: remove hardcoded categories

* chore: categories from connector

* chore: remaining methods

* chore: remove unused method

* fix: removing duplicate interfaces

* chore: cleanups

* fix: add DocumentModelStore dependency for event handling

* fix: linting

* fix: dropdown

* fix: again, linting

* chore: don't need the double label

* fix: missing label

* chore: small tweaks

* chore: name

* chore(revit-mapper): css

* chore(revit-mapper): correct routing

* fix(revit-mapper): revit integration buttons

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
This commit is contained in:
Björn Steinhagen
2025-08-06 19:55:44 +08:00
committed by GitHub
parent 48bb180899
commit 669afe81cf
5 changed files with 341 additions and 1 deletions
+14
View File
@@ -96,6 +96,7 @@
Getting started
</FormButton>
</div>
<!--
<FormButton
text
@@ -114,6 +115,19 @@
</span>
</FormButton> -->
</div>
<!--Revit Integration button (only if mapper binding exists)-->
<div v-if="app.$revitMapperBinding" class="mt-2">
<hr class="border-outline-2 mb-2" />
<FormButton
v-tippy="'Map objects to Revit categories'"
size="sm"
color="outline"
full-width
@click="$router.push('/revit-mapper')"
>
Revit Integration
</FormButton>
</div>
</LayoutPanel>
</div>
<div v-if="accounts.length !== 0 && !hasNoModelCards" class="space-y-2 pb-24">
+212
View File
@@ -0,0 +1,212 @@
<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">
Home
</FormButton>
<hr />
</div>
<!-- Step 1: Selection Mode (currently only support by selection) -->
<div class="px-2">
<p class="h5">Selection</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>
</div>
</div>
</div>
<!-- Step 2: Category Selection (only shown when objects are selected) -->
<div v-if="hasObjectsSelected" 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-1">
<FormSelectBase
key="label"
v-model="selectedCategory"
name="categoryMapping"
placeholder="Select a category"
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="{ value }">
<span class="text-primary text-xs">
{{ Array.isArray(value) ? value[0]?.label : value.label }}
</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item.label }}</span>
</template>
</FormSelectBase>
</div>
<!-- Apply button - same height as dropdown -->
<FormButton
color="primary"
size="sm"
class="h-8"
:disabled="!selectedCategory"
@click="assignToCategory()"
>
Apply Mapping
</FormButton>
</div>
</div>
</div>
<hr v-if="hasObjectsSelected" />
<!-- Step 3: Mappings Summary Table -->
<div v-if="mappings.length > 0" class="px-2">
<p class="h5">Current Mappings</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>
</div>
</div>
<!-- Clear all button separated, with custom margin -->
<div class="flex justify-end">
<FormButton size="sm" color="danger" @click="clearAllMappings()">
Clear All
</FormButton>
</div>
</div>
</div>
</template>
<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 { useSelectionStore } from '~/store/selection'
import type {
Category,
CategoryMapping
} from '~/lib/bindings/definitions/IRevitMapperBinding'
// === STORE INTEGRATION ===
const selectionStore = useSelectionStore()
const { selectionInfo, hasBinding: hasSelectionBinding } = storeToRefs(selectionStore)
const { $revitMapperBinding, $baseBinding } = useNuxtApp()
// === REACTIVE STATE ===
const categoryOptions = ref<Category[]>([])
const selectedCategory = ref<Category | undefined>(undefined)
const mappings = ref<CategoryMapping[]>([])
// === 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 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()
}
// === OBJECT SELECTION ===
const selectMappedObjects = async (mapping: CategoryMapping) => {
await $baseBinding?.highlightObjects(mapping.objectIds)
}
// === LIFECYCLE ===
onMounted(async () => {
$revitMapperBinding?.on('mappingsChanged', (updatedMappings: CategoryMapping[]) => {
mappings.value = updatedMappings
})
await loadCategories()
await refreshMappings()
if (hasSelectionBinding.value) {
selectionStore.refreshSelectionFromHostApp()
}
})
</script>