Merge pull request #146 from specklesystems/dogukan/cnx-1502-merge-tables-of-federated-models
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled

feat: federated models
This commit is contained in:
Dogukan Karatas
2025-04-30 16:18:44 +02:00
committed by GitHub
10 changed files with 201 additions and 287 deletions
@@ -4,6 +4,7 @@
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
@@ -22,15 +23,90 @@
Detail = [File = fileName, Error = e]
],
// get model name
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
// parse the URL to determine if it's a federated model
parsedUrl = Parser(url),
// get structured data
StructuredData = GetStructuredData(url),
// function to process a single model and get its data
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
let
// construct a standard URL for the model
singleModelUrl = Text.Combine({
baseUrl,
"/projects/",
projectId,
"/models/",
modelId,
if versionId <> null then Text.Combine({"@", versionId}) else ""
}),
// get model info
modelInfo = GetModel(singleModelUrl),
rootObjectId = modelInfo[rootObjectId],
modelName = modelInfo[modelName],
// get structured data
structuredData = GetStructuredData(singleModelUrl),
// add the model name as context
result = Table.AddColumn(
structuredData,
"Source Model",
each modelName,
type text
)
in
[
Data = result,
RootObjectId = rootObjectId
],
// check if this is a federated model
results = if parsedUrl[isFederated] = true then
// process each model in the federation
let
modelsData = List.Transform(
parsedUrl[federatedModels],
each ProcessSingleModel(
parsedUrl[baseUrl],
parsedUrl[projectId],
[modelId],
[versionId]
)
),
// extract all data tables
allTables = List.Transform(modelsData, each [Data]),
// extract all root object IDs
allRootIds = List.Transform(modelsData, each [RootObjectId]),
// combine all root object IDs into a comma-separated string
combinedRootIds = Text.Combine(allRootIds, ","),
// combine all data tables
combinedData = Table.Combine(allTables),
// replace the "Version Object ID" column with the combined root IDs
finalData = Table.TransformColumns(
combinedData,
{"Version Object ID", each combinedRootIds}
)
in
finalData
else
// use existing functionality for single models
let
// get model name
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
// get structured data
structuredData = GetStructuredData(url),
// rename column based on send status
NewColumnName = "Version Object ID",
Result = Table.RenameColumns(StructuredData, {{"Version Object ID", NewColumnName}})
// rename column based on send status
newColumnName = "Version Object ID",
result = Table.RenameColumns(structuredData, {{"Version Object ID", newColumnName}})
in
result
in
Result
results
@@ -11,29 +11,48 @@
then pathSegments{1} else null,
// extract model ID and version ID if they exist
modelAndVersion = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
then Text.Split(pathSegments{3}, "@") else {},
rawModelSegment = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
then pathSegments{3} else "",
// separate model ID from version ID
modelId = if List.Count(modelAndVersion) > 0
then modelAndVersion{0} else null,
// check if this is a federated model (contains commas)
isFederated = Text.Contains(rawModelSegment, ","),
// if federated, split by comma to get multiple model IDs
modelSegments = if isFederated
then Text.Split(rawModelSegment, ",")
else {rawModelSegment},
// get version ID if it exists
versionId = if List.Count(modelAndVersion) > 1
then modelAndVersion{1} else null,
// process each model segment (could be modelID or modelID@versionID)
processedModels = List.Transform(
modelSegments,
each [
modelId = if Text.Contains(_, "@")
then Text.Split(_, "@"){0}
else _,
versionId = if Text.Contains(_, "@")
then Text.Split(_, "@"){1}
else null
]
),
// extract model IDs and version IDs into separate lists
modelIds = List.Transform(processedModels, each [modelId]),
versionIds = List.Transform(processedModels, each [versionId]),
// validate URL structure
isValid = projectId <> null and modelId <> null
isValid = projectId <> null and List.Count(modelIds) > 0 and List.First(modelIds) <> ""
in
if not isValid then
error [
Reason = "Invalid URL",
Message = "The URL must be in the format 'https://server/projects/PROJECT_ID/models/MODEL_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID'"
Message = "The URL must be in the format 'https://server/projects/PROJECT_ID/models/MODEL_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID1,MODEL_ID2'"
]
else
[
baseUrl = baseUrl,
projectId = projectId,
modelId = modelId,
versionId = versionId
]
modelId = if isFederated then null else processedModels{0}[modelId],
versionId = if isFederated then null else processedModels{0}[versionId],
isFederated = isFederated,
federatedModels = if isFederated then processedModels else null
]
@@ -23,8 +23,7 @@
import { inject, onBeforeUnmount, onMounted, Ref, ref } from 'vue'
import { currentOS, OS } from '../utils/detectOS'
import ViewerControls from 'src/components/ViewerControls.vue'
import ViewModeControls from 'src/components/ViewModeControls.vue'
import { CanonicalView, SpeckleView } from '@speckle/viewer'
import { SpeckleView } from '@speckle/viewer'
import { useClickDragged } from 'src/composables/useClickDragged'
import { ContextOption } from 'src/settings/colorSettings'
import { useVisualStore } from '@src/store/visualStore'
@@ -63,6 +62,9 @@ function isMultiSelect(e: MouseEvent) {
async function onCanvasClick(ev: MouseEvent) {
if (dragged.value) return
// eslint-disable-next-line no-debugger
debugger
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
const multi = isMultiSelect(ev)
@@ -1,209 +0,0 @@
import {
CanonicalView,
FilteringState,
LegacyViewer,
IntersectionQuery,
DefaultViewerParams,
SpeckleView,
CameraController,
CameraEvent,
SpeckleOfflineLoader
} from '@speckle/viewer'
import { pickViewableHit, projectToScreen } from '../utils/viewerUtils'
import _ from 'lodash'
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { PerspectiveCamera, OrthographicCamera, Box3 } from 'three'
/**
* @deprecated
*/
export default class ViewerHandler {
private viewer: LegacyViewer
private readonly parent: HTMLElement
private state: FilteringState
private loadedObjectsCache: Set<string> = new Set<string>()
private config = {
authToken: null,
batchSize: 25
}
private currentSectionBox: Box3 = null
private currentSettings: SpeckleVisualSettingsModel
public getViews() {
return this.viewer.getViews()
}
public updateSettings(settings: SpeckleVisualSettingsModel) {
// Camera settings
switch (settings.camera.projection.value) {
case 'perspective':
this.viewer.setPerspectiveCameraOn()
break
case 'orthographic':
this.viewer.setOrthoCameraOn()
break
}
var camController = this.viewer.getExtension(CameraController)
var angle = settings.camera.allowCameraUnder.value ? Math.PI : Math.PI / 2
camController.options = { maximumPolarAngle: angle }
// Lighting settings
const newConfig = settings.lighting.getViewerConfiguration()
this.viewer.setLightConfiguration(newConfig)
this.currentSettings = settings
}
public setView(view: SpeckleView | CanonicalView) {
this.viewer.setView(view)
}
public setSectionBox(active: boolean, objectIds: string[]) {
if (active) {
if (this.currentSectionBox === null) {
const bbox = this.viewer.getSectionBoxFromObjects(objectIds)
this.viewer.setSectionBox(bbox)
this.currentSectionBox = bbox as unknown as Box3
} else {
const bbox = this.viewer.getCurrentSectionBox()
if (bbox) this.currentSectionBox = bbox as unknown as Box3
}
this.viewer.sectionBoxOn()
} else {
this.viewer.sectionBoxOff()
}
this.viewer.requestRender()
}
public addCameraUpdateEventListener(listener: (ev) => void) {
this.viewer.getExtension(CameraController).on(CameraEvent.LateFrameUpdate, listener)
}
public constructor(parent: HTMLElement) {
this.parent = parent
}
public async init() {
if (this.viewer) return
const viewerSettings = DefaultViewerParams
viewerSettings.showStats = false
viewerSettings.verbose = false
const viewer = new LegacyViewer(this.parent, viewerSettings)
await viewer.init()
console.log('Viewer initialized', viewer)
this.viewer = viewer
}
public async unloadObjects(
objects: string[],
signal?: AbortSignal,
onObjectUnloaded?: (url: string) => void
) {
for (const url of objects) {
if (signal?.aborted) return
await this.viewer
.cancelLoad(url, true)
.catch((e) => console.warn('Viewer Unload error', url, e))
.finally(() => {
if (this.loadedObjectsCache.has(url)) this.loadedObjectsCache.delete(url)
if (onObjectUnloaded) onObjectUnloaded(url)
})
}
}
public async loadObjectsWithAutoUnload(
objects: object[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void,
signal: AbortSignal
) {
// var objectsToUnload = _.difference([...this.loadedObjectsCache], rootObject)
// await this.unloadObjects(objectsToUnload, signal)
// await this.loadObjects(obj, onLoad, onError) // TODO: pass root object
await this.loadObjects(objects, onLoad, onError)
}
public async loadObjects(
objects: object[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void
) {
const stringifiedObject = JSON.stringify(objects)
const loader = new SpeckleOfflineLoader(this.viewer.getWorldTree(), stringifiedObject)
void this.viewer.unloadAll()
void this.viewer.loadObject(loader, true)
}
public async intersect(coords: { x: number; y: number }) {
const point = this.viewer.Utils.screenToNDC(
coords.x,
coords.y,
this.parent.clientWidth,
this.parent.clientHeight
)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
}
const res = this.viewer.query(intQuery)
if (!res) return null
return {
hit: pickViewableHit(res.objects, this.state),
objects: res.objects
}
}
public zoom(objectIds?: string[]) {
this.viewer.zoom(objectIds)
}
public zoomExtents() {
this.viewer.zoom()
}
public async unIsolateObjects() {
if (this.state.isolatedObjects)
this.state = await this.viewer.unIsolateObjects(this.state.isolatedObjects, 'powerbi', true)
}
public async isolateObjects(objectIds, ghost = false) {
this.state = await this.viewer.isolateObjects(objectIds, 'powerbi', true, ghost)
}
public async colorObjectsByGroup(
groups?: {
objectIds: string[]
color: string
}[]
) {
this.state = await this.viewer.setUserObjectColors(groups ?? [])
}
public async clear() {
if (this.viewer) await this.viewer.unloadAll()
this.loadedObjectsCache.clear()
}
public async selectObjects(objectIds: string[] = null) {
if (!this.viewer) return
await this.viewer.resetHighlight()
const objIds = objectIds ?? []
this.state = await this.viewer.selectObjects(objIds)
}
public getScreenPosition(worldPosition): { x: number; y: number } {
return projectToScreen(
this.viewer.getExtension(CameraController).renderingCamera as unknown as
| PerspectiveCamera
| OrthographicCamera,
worldPosition
)
}
public dispose() {
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
this.viewer = null
}
}
+35 -26
View File
@@ -11,7 +11,7 @@ import {
Viewer,
HybridCameraController,
SelectionExtension,
FilteringExtension,
FilteringExtension
} from '@speckle/viewer'
import { SpeckleObjectsOfflineLoader } from '@src/laoder/SpeckleObjectsOfflineLoader'
import { useVisualStore } from '@src/store/visualStore'
@@ -27,7 +27,6 @@ export interface IViewer {
on: <E extends keyof IViewerEvents>(event: E, callback: IViewerEvents[E]) => void
}
export interface Hit {
guid: string
object?: Record<string, unknown>
@@ -99,7 +98,7 @@ export class ViewerHandler {
}
public zoomExtends = () => this.cameraControls.setCameraView(undefined, false)
public setView = (view: CanonicalView) => this.cameraControls.setCameraView(view, false)
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
@@ -145,7 +144,7 @@ export class ViewerHandler {
public intersect = (coords: { x: number; y: number }) => {
const point = this.viewer.Utils.screenToNDC(coords.x, coords.y)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
@@ -163,24 +162,32 @@ export class ViewerHandler {
}
}
public loadObjects = async (objects: object[]) => {
public loadObjects = async (modelObjects: object[][]) => {
await this.viewer.unloadAll()
// const stringifiedObject = JSON.stringify(objects)
//@ts-ignore
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
const store = useVisualStore()
const speckleViews = objects.filter(
const store = useVisualStore()
const speckleViews = []
modelObjects.forEach(async (objects) => {
//@ts-ignore
(o) => o.speckle_type === 'Objects.BuiltElements.View:Objects.BuiltElements.View3D'
) as SpeckleView[]
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
const speckleViewsInModel = objects.filter(
//@ts-ignore
(o) => o.speckle_type === 'Objects.BuiltElements.View:Objects.BuiltElements.View3D'
) as SpeckleView[]
speckleViews.concat(speckleViewsInModel)
// Since you are setting another camera position, maybe you want the second argument to false
await this.viewer.loadObject(loader, true)
})
store.setSpeckleViews(speckleViews)
if (store.defaultViewModeInFile) {
this.setViewMode(Number(store.defaultViewModeInFile))
}
// Since you are setting another camera position, maybe you want the second argument to false
await this.viewer.loadObject(loader, true)
Tracker.dataLoaded({ sourceHostApp: store.receiveInfo.sourceApplication })
// camera need to be set after objects loaded
if (store.cameraPosition) {
@@ -204,26 +211,28 @@ export class ViewerHandler {
private pickViewableHit(hits: Hit[]): Hit | null {
// The current filtering state
const filteringState = this.filtering.filteringState;
const filteringState = this.filtering.filteringState
// Are there any objects isolated?
const hasIsolatedObjects =
!!filteringState.isolatedObjects &&
filteringState.isolatedObjects.length !== 0;
!!filteringState.isolatedObjects && filteringState.isolatedObjects.length !== 0
// Are there any objects hidden?
const hasHiddenObjects =
!!filteringState.hiddenObjects &&
filteringState.hiddenObjects.length !== 0;
const hasHiddenObjects =
!!filteringState.hiddenObjects && filteringState.hiddenObjects.length !== 0
// No isolated or hidden objects? Return the first hit
if(!hasIsolatedObjects && !hasHiddenObjects)
return hits[0]
if (hasIsolatedObjects && !hasHiddenObjects) {
return hits.find((h) => filteringState.isolatedObjects.includes(h.guid))
}
for(let k = 0 ; k < hits.length ; k++){
for (let k = 0; k < hits.length; k++) {
/** Return the first one that's not hidden or isolated. */
if((hasIsolatedObjects && filteringState.isolatedObjects?.includes(hits[k].guid)) &&
(hasHiddenObjects && filteringState.hiddenObjects?.includes(hits[k].guid)))
if (
hasIsolatedObjects &&
filteringState.isolatedObjects?.includes(hits[k].guid) &&
hasHiddenObjects &&
filteringState.hiddenObjects?.includes(hits[k].guid)
)
return hits[k]
}
}
public dispose() {
@@ -239,7 +248,7 @@ const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
viewerSettings.verbose = true // Turning this on so we can see logs for now
const viewer = new Viewer(parent, viewerSettings)
await viewer.init()
viewer.createExtension(HybridCameraController) // camera controller
viewer.createExtension(SelectionExtension) // selection helper
// viewer.createExtension(SectionTool) // section tool, possibly not needed for now?
+8 -7
View File
@@ -1,7 +1,7 @@
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
import { IViewerEvents } from '@src/plugins/viewer'
import { SpeckleDataInput } from '@src/types'
import { zipJSONChunks } from '@src/utils/compression'
import { zipJSONChunks, zipModelObjects } from '@src/utils/compression'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { defineStore } from 'pinia'
import { Vector3 } from 'three'
@@ -101,7 +101,7 @@ export const useVisualStore = defineStore('visualStore', () => {
}
const loadObjectsFromFile = async (objects: object[]) => {
lastLoadedRootObjectId.value = (objects[0] as SpeckleObject).id
lastLoadedRootObjectId.value = (objects[0] as SpeckleObject).id // TODO fix
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
await viewerEmit.value('loadObjects', objects)
@@ -120,13 +120,14 @@ export const useVisualStore = defineStore('visualStore', () => {
dataInput.value = newValue
if (viewerReloadNeeded.value) {
lastLoadedRootObjectId.value = (dataInput.value.objects[0] as SpeckleObject).id
const modelIds = dataInput.value.modelObjects.map((o) => (o[0] as SpeckleObject).id).join(',')
lastLoadedRootObjectId.value = modelIds
console.log(`🔄 Forcing viewer re-render for new root object id.`)
await viewerEmit.value('loadObjects', dataInput.value.objects)
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
clearLoadingProgress()
viewerReloadNeeded.value = false
isViewerObjectsLoaded.value = true
writeObjectsToFile(dataInput.value.objects)
writeObjectsToFile(dataInput.value.modelObjects)
}
if (dataInput.value.selectedIds.length > 0) {
@@ -137,8 +138,8 @@ export const useVisualStore = defineStore('visualStore', () => {
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
const writeObjectsToFile = (objects: object[]) => {
const compressedChunks = zipJSONChunks(objects, 10000) // Compress in chunks
const writeObjectsToFile = (modelObjects: object[][]) => {
const compressedChunks = zipModelObjects(modelObjects, 10000) // Compress in chunks
host.value.persistProperties({
merge: [
+1 -1
View File
@@ -10,7 +10,7 @@ export interface IViewerTooltip {
}
export interface SpeckleDataInput {
objects: object[]
modelObjects: object[][]
objectIds: string[]
selectedIds: string[]
colorByIds: { objectIds: string[]; slice: fs.ColorPicker; color: string }[]
+14 -2
View File
@@ -29,11 +29,15 @@ function chunkArray(array, chunkSize) {
return chunks
}
export function zipModelObjects(modelObjects: object[][], chunkSize = 1000) {
return modelObjects.map((objects) => zipJSONChunks(objects, chunkSize)).join('>')
}
/**
* Compresses JSON objects in chunks properly.
*/
export function zipJSONChunks(objects, chunkSize = 1000) {
const chunks = chunkArray(objects, chunkSize)
export function zipJSONChunks(objectsInModel: object[], chunkSize = 1000) {
const chunks = chunkArray(objectsInModel, chunkSize)
return chunks.map((chunk, index) => {
const jsonString = JSON.stringify(chunk)
const originalSize = new TextEncoder().encode(jsonString).length / (1024 * 1024) // Original size in bytes
@@ -52,6 +56,14 @@ export function zipJSONChunks(objects, chunkSize = 1000) {
})
}
export function unzipModelObjects(compressedChunk: string) {
const compressedModelObjects = compressedChunk.split('>')
console.log(compressedModelObjects)
return compressedModelObjects.map((compressedModelObjs) =>
unzipJSONChunk(compressedModelObjs.split(','))
)
}
/**
* Decompresses JSON chunks properly.
*/
@@ -149,7 +149,8 @@ export type ReceiveInfo = {
async function getReceiveInfo(id) {
try {
const response = await fetch(`http://localhost:29364/user-info/${id}`)
const ids = (id as string).split(',')
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
if (!response.body) {
console.error('No response body')
return
@@ -162,7 +163,18 @@ async function getReceiveInfo(id) {
}
}
async function fetchStreamedData(id) {
async function fetchStreamedData(commaSeparatedModelIds: string) {
const modelIds = (commaSeparatedModelIds as string).split(',')
const modelObjects = []
for await (const id of modelIds) {
const objects = await fetchStreamedDataForModel(id)
modelObjects.push(objects)
}
return modelObjects
}
async function fetchStreamedDataForModel(id) {
try {
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
@@ -263,7 +275,7 @@ export async function processMatrixView(
console.log('🗝️ Root Object Id: ', id)
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
let objects: object[] = undefined
let modelObjects: object[][] = undefined
if (visualStore.isLoadingFromFile) {
console.log('The data is loading from file, skipping the streaming it.')
@@ -275,7 +287,7 @@ export async function processMatrixView(
visualStore.setLoadingProgress('Loading', null)
// stream data
objects = await fetchStreamedData(id)
modelObjects = await fetchStreamedData(id)
const receiveInfo = await getReceiveInfo(id)
if (receiveInfo) {
@@ -352,7 +364,7 @@ export async function processMatrixView(
previousPalette = host.colorPalette['colorPalette']
return {
objects,
modelObjects,
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
+5 -13
View File
@@ -4,10 +4,8 @@ import '../style/visual.css'
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
import { createApp } from 'vue'
import App from './App.vue'
// import { store } from 'src/store'
import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { Tracker } from './utils/mixpanel'
import { SpeckleDataInput } from './types'
import { processMatrixView, ReceiveInfo, validateMatrixView } from './utils/matrixViewUtils'
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
@@ -19,15 +17,10 @@ import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructor
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import IVisual = powerbi.extensibility.visual.IVisual
import ITooltipService = powerbi.extensibility.ITooltipService
import {
createDataViewWildcardSelector,
DataViewWildcardMatchingOption
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import { ColorSelectorSettings } from 'src/settings/colorSettings'
import { pinia } from './plugins/pinia'
import { FieldInputState, useVisualStore } from './store/visualStore'
import { unzipJSONChunk, unzipJSONChunks, zipJSONChunks } from './utils/compression'
import { useVisualStore } from './store/visualStore'
import { unzipModelObjects } from './utils/compression'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
@@ -103,10 +96,9 @@ export class Visual implements IVisual {
this.isFirstViewerLoad &&
options.dataViews[0].metadata.objects
) {
const chunks = (
options.dataViews[0].metadata.objects.storedData?.speckleObjects as string
).split(',')
const objectsFromFile = unzipJSONChunks(chunks)
const chunks = options.dataViews[0].metadata.objects.storedData
?.speckleObjects as string
const objectsFromFile = unzipModelObjects(chunks)
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
console.log(