Compare commits

..

44 Commits

Author SHA1 Message Date
Mucahit Bilal GOKER 68d6bf3d55 Merge pull request #180 from specklesystems/dogukan/cnx-1978-control-camera-animation-on-change
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
feat (visual): toogle for animation on action
2025-07-08 16:14:31 +03:00
Dogukan Karatas 2a8925c8ef icon updated 2025-07-08 15:08:47 +02:00
Mucahit Bilal GOKER f9b3d3db52 Merge pull request #181 from specklesystems/dogukan/cnx-2089-auto-extract-properties
feat (data): extract properties column by default
2025-07-08 15:41:06 +03:00
Mucahit Bilal GOKER bfd0c33373 Merge pull request #179 from specklesystems/bilal/cnx-2106-federate-models-helper-function
Helper Function: Federate Models
2025-07-08 15:39:36 +03:00
Dogukan Karatas d155a4b165 registered to the capabilities 2025-07-08 13:37:17 +02:00
Dogukan Karatas 2e9ece856f adds properties extractor by default 2025-07-07 15:49:38 +02:00
Dogukan Karatas 808e288848 adds option for zoom on select 2025-07-07 14:18:18 +02:00
bimgeek 701116c66c helper function: federate models 2025-07-07 10:48:05 +03:00
Dogukan Karatas e73d392013 Merge pull request #178 from specklesystems/dogukan/cnx-1932-saving-visual-with-filtered-data
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix (visual): persistent visual filters
2025-07-04 12:51:52 +02:00
Dogukan Karatas dd7f3fe95d cover initial states 2025-07-03 15:54:09 +02:00
Dogukan Karatas fdcc1f2cef listen load event 2025-07-03 15:42:06 +02:00
Dogukan Karatas 5e2f108e49 Merge pull request #177 from specklesystems/dogukan/cnx-2032-persistent-hide-nav-bar-toggle
fix (visual): persistent navbar toogle
2025-07-01 15:01:48 +02:00
Dogukan Karatas df334e95a2 workspace to viewmode 2025-07-01 14:53:03 +02:00
Dogukan Karatas ce733d1ced adds persistent navbar 2025-07-01 11:15:59 +02:00
Dogukan Karatas 8345258990 Merge pull request #176 from specklesystems/dogukan/fix-function-registration
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix: helper function registration
2025-06-24 15:22:12 +02:00
Dogukan Karatas dbd0f2f9ce fix registration 2025-06-24 15:19:11 +02:00
Dogukan Karatas c9b4155660 Merge pull request #174 from specklesystems/bilal/object-compound-structure
added objects composite structure
2025-06-24 14:56:26 +02:00
Dogukan Karatas c4d094d722 Merge branch 'dev' into bilal/object-compound-structure 2025-06-24 14:55:06 +02:00
Dogukan Karatas 5b003b182b Merge pull request #173 from specklesystems/bilal/object-materialquantities
object material quantities added
2025-06-24 14:54:36 +02:00
Dogukan Karatas 7eabd47f6d Merge pull request #175 from specklesystems/dogukan/cnx-2035-loading-got-veeeeeeery-sloooooooow
performance fix: remove `displayValue` exclusion
2025-06-24 14:54:24 +02:00
Dogukan Karatas 822b999be9 remove displayValue exclusion 2025-06-24 13:47:30 +02:00
Dogukan Karatas 2407b8758a Merge pull request #171 from specklesystems/dogukan/exclude-display-value
feat(data): exclude displayValue in data
2025-06-24 11:09:48 +02:00
Mucahit Bilal GOKER d6f5e65bd7 added objects composite structure 2025-06-19 15:28:41 +03:00
Mucahit Bilal GOKER 183cc36654 object material quantities added 2025-06-19 15:15:05 +03:00
Mucahit Bilal GOKER a740272585 Merge pull request #170 from specklesystems/bilal/expand-record-util
expand record helper function
2025-06-19 15:11:51 +03:00
Dogukan Karatas 72128a9f4e excludes displayValues in data 2025-06-19 13:55:41 +02:00
Mucahit Bilal GOKER 677c663ef3 Merge branch 'dev' into bilal/expand-record-util 2025-06-19 14:55:21 +03:00
Mucahit Bilal GOKER a077857c66 add new line 2025-06-19 14:40:16 +03:00
Mucahit Bilal GOKER 5897a286bc expand record helper function 2025-06-19 14:23:40 +03:00
Dogukan Karatas 5b49fb2a9a Merge pull request #169 from specklesystems/dogukan/revit-helper-functions
feat (data): get properties function
2025-06-19 13:07:57 +02:00
Dogukan Karatas 424404dd11 Merge branch 'dogukan/revit-helper-functions' of https://github.com/specklesystems/speckle-powerbi into dogukan/revit-helper-functions 2025-06-19 13:04:18 +02:00
Dogukan Karatas 31312522a7 adds objects.collections 2025-06-19 12:47:18 +02:00
Mucahit Bilal GOKER 932198dccf rename GetProperties to Objects.Properties 2025-06-19 13:47:13 +03:00
Dogukan Karatas 3770502ca4 adds function overload 2025-06-18 16:30:36 +02:00
Dogukan Karatas 93e8fcdd9d gets duplicated props 2025-06-18 16:13:42 +02:00
Dogukan Karatas 370052b2be GetProperties added 2025-06-18 14:41:14 +02:00
Oğuzhan Koral aa4a137a0d handle size in MB separately (#168)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
2025-06-16 21:17:43 +03:00
Dogukan Karatas 4acdf30734 error handling (#167)
Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2025-06-16 12:05:21 +00:00
Dogukan Karatas b531446acd feat (visual): send connector version to mixpanel (#165)
* connector version added

* updated props
2025-06-16 15:03:54 +03:00
Jedd Morgan de1b2ca39c Run CI on PR (#164) 2025-06-06 10:14:00 +01:00
Jedd Morgan 5642da1e57 Ensure bash (#163)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-05-30 18:38:18 +03:00
Jedd Morgan 276d0c3a76 removed last trace of gitversion (#162) 2025-05-30 18:34:21 +03:00
Jedd Morgan 2d92b85687 fix(ci): replace gitversion with tag parsing (#161)
* remove git version

* delete unused gitversion

* disable circle ci
2025-05-30 16:27:57 +01:00
Oğuzhan Koral 82bd109b85 Feat: visual revamp (#156)
* delete debugger

* No need tooltip data as part of interactivity

* Fix coloring

* delete logging

* Fix messaging on interactivity for tooltip data

* Delete unused code

* Navbar and cursors

* Revamp viewer actions

* Hide viewer actions

* not a css master commit

* Toggle projection/orthi

* Remove console log

* Fix saved objects

* Remove console log

* Sort performance logging

* fix view mode cache

* Fix initial isolate issue

* Update README.md (#147)

* Update README.md

* Update README.md

* fix typo on conditions

* capabilities for object ids

* Revert isolating every setDataInput

* Fix selection issues

* Fix tooltip fckp

* Reset filters

* Fix reset filter

* Ghost hidden on filter

* Bring conditional formatting back

* Remove ghost hidden context from color card

* Disable shadow catcher

* Disable camera position persistence for performance reasons

* feat (data): sending version and branding info (#157)

* get version

* adds workspace info

* adds hideBranding

* adds workspace info

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>

* Enrich receive info from desktop service

* test

* fix path

* fix path again

* file version

* correct the version with assembly for file version

* sanitize tag

* Use version from receive info

* Fix clipped zoom extend

* Workspace name logo

* Add can hide branding logic

* Fix tooltip for toggle

* Fix capabilities for storing branding

* Tooltips

* Store is ortho in file

* Store camera position and target into file according to selected view

* Fix loading bar reactivity

* Update connector flow

* Fix ghost state

* Store is ghost in file

* Consider ghost value on reset filter

* More

* excludes rawencoding (#159)

* adds null check for personal (#158)

* Progress update and error handling

* Call pre get before

---------

Co-authored-by: Jonathon Broughton <760691+jsdbroughton@users.noreply.github.com>
Co-authored-by: Dogukan Karatas <61163577+dogukankaratas@users.noreply.github.com>
2025-05-30 17:43:21 +03:00
34 changed files with 1457 additions and 447 deletions
-3
View File
@@ -1,3 +0,0 @@
for /f "tokens=1 delims=-" %%i in ("%CIRCLE_TAG%") do set "TAG=%%i.%WORKFLOW_NUM%"
for /f "tokens=1 delims=/" %%j in ("%CIRCLE_TAG%") do set "SEMVER=%%j"
tools\InnoSetup\ISCC.exe tools\powerbi.iss /Sbyparam=$p /DINFO_VERSION=%TAG% /DVERSION=%SEMVER% %*
+11 -166
View File
@@ -1,171 +1,16 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
orbs:
win: circleci/windows@5.0
commands:
setup_digicert:
description: Set up Digicert Keylocker certificate for code-signing
steps:
- run:
name: "Digicert Signing Manager Setup"
command: |
cd C:\
curl.exe -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:$env:SM_API_KEY" -o smtools-windows-x64.msi
msiexec.exe /i smtools-windows-x64.msi /quiet /qn | Wait-Process
- run:
name: Setup Digicert ONE Client Cert
command: |
cd C:\
echo $env:SM_CLIENT_CERT_FILE_B64 > certificate.txt
certutil -decode certificate.txt certificate.p12
- run:
name: Sync Certs
command: |
& $env:SSM\smksp_cert_sync.exe
# Define the jobs we want to run for this project
jobs:
build-visual:
docker:
- image: cimg/node:18.20.3
steps:
- checkout
- run: node --version
- run:
name: "npm install"
command: "npm i"
working_directory: src/powerbi-visual
- run:
name: Set version
command: |
npm version ${CIRCLE_TAG:-2.0.0} --allow-same-version
working_directory: src/powerbi-visual
- run:
name: "npm run build"
command: "npm run build"
working_directory: src/powerbi-visual
- store_artifacts:
path: dist/*.pbiviz
- persist_to_workspace:
root: ./
paths:
- src/powerbi-visual/dist/*.pbiviz
build-connector:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- run:
name: "Set connector internal version"
command: |
$env:VERSION = if([string]::IsNullOrEmpty($env:CIRCLE_TAG)) { "2.0.0" } else { $env:CIRCLE_TAG }
(Get-Content ./Speckle.pq).replace('[Version = "2.0.0"]', '[Version = "'+$($env:VERSION)+'"]') | Set-Content ./Speckle.pq
working_directory: src/powerbi-data-connector
- run:
name: "Build Data Connector"
command: "msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true"
working_directory: src/powerbi-data-connector
- run:
name: Create PQX file
command: .\tools\MakePQX\MakePQX.exe pack -mz src/powerbi-data-connector/bin/Speckle.mez -t src/powerbi-data-connector/bin/Speckle.pqx
- persist_to_workspace:
root: ./
paths:
- src/powerbi-data-connector/bin/Speckle.pqx
build-installer:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- attach_workspace:
at: ./
- unless: # Build installers unsigned on non-tagged builds
condition: << pipeline.git.tag >>
steps:
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
CIRCLE_TAG: 2.0.0
command: .circleci\build-installer.bat
- when: # Setup certificates and build installers signed for tagged builds
condition: << pipeline.git.tag >>
steps:
- setup_digicert
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
command: .circleci\build-installer.bat /DSIGN_INSTALLER /DCODE_SIGNING_CERT_FINGERPRINT=%SM_CODE_SIGNING_CERT_SHA1_HASH%
- store_artifacts:
path: ./installer
- persist_to_workspace:
root: ./
paths:
- installer/*.exe
deploy-connector-to-feed:
docker:
- image: mcr.microsoft.com/dotnet/sdk:6.0
steps:
- attach_workspace:
at: ./
- run:
name: Install Manager Feed CLI
command: dotnet tool install --global Speckle.Manager.Feed
- run:
name: Upload new version
command: |
TAG=$(if [ "${CIRCLE_TAG}" ]; then echo $CIRCLE_TAG; else echo "2.0.0"; fi;)
SEMVER=$(echo "$TAG" | sed -e 's/\/[a-zA-Z-]*//')
VER=$(echo "$SEMVER" | sed -e 's/-.*//')
VERSION=$(echo $VER.$WORKFLOW_NUM)
/root/.dotnet/tools/Speckle.Manager.Feed deploy -s powerbi -v ${SEMVER} -u https://releases.speckle.dev/installers/powerbi/powerbi-${SEMVER}.exe -o Win -a Any -f ./installer/powerbi-${SEMVER}.exe
environment:
WORKFLOW_NUM: << pipeline.number >>
workflows:
build:
docker:
- image: cimg/base:2023.03
steps:
- run: echo "so long and thanks for all the fish"
# Orchestrate our job run sequence
workflows:
build_and_test:
when: false
jobs:
- build-connector:
context: digicert-keylocker
- build-visual
- build-installer:
context: digicert-keylocker
requires:
- build-connector
- build-visual
deploy:
jobs:
- build-connector:
filters: &deploy_filter
branches:
ignore: /.*/
tags:
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-\w{1,10})?$/
context: digicert-keylocker
- build-visual:
filters: *deploy_filter
- build-installer:
filters: *deploy_filter
context: digicert-keylocker
requires:
- build-connector
- build-visual
- deploy-connector-to-feed:
filters: *deploy_filter
requires:
- build-installer
context: do-spaces-speckle-releases
- build
@@ -1,4 +1,4 @@
name: build_powerbi
name: Build and deploy Connector and Visual
on:
push:
branches: ["installer-test/**"]
@@ -8,32 +8,39 @@ jobs:
runs-on: windows-latest
outputs:
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
file-version: ${{ steps.set-version.outputs.file-version }}
env:
CertFile: "./speckle.pfx"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- id: set-version
name: Set version to output
shell: bash
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- name: Set connector version
run: |
python patch_version.py ${{steps.gitversion.outputs.AssemblySemVer}}
python patch_version.py ${{steps.set-version.outputs.file-version}}
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
@@ -60,37 +67,35 @@ jobs:
if-no-files-found: error
retention-days: 1
- id: set-version
name: Set version to output
run: echo "semver=${{steps.gitversion.outputs.semVer}}" >> "$GITHUB_OUTPUT"
shell: bash
- id: set-info-version
name: Set version to output
run: echo "file-version=${{steps.gitversion.outputs.AssemblySemVer}}" >> "$GITHUB_OUTPUT"
shell: bash
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- id: set-version
name: Set version to output
shell: bash
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- run: npm ci
working-directory: src/powerbi-visual
- run: npm version ${{steps.gitversion.outputs.semVer}} --allow-same-version
- run: npm version ${{steps.set-version.outputs.semver}} --allow-same-version
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
+30
View File
@@ -0,0 +1,30 @@
name: Test Build Connector and Visual
on: pull_request
jobs:
build-connector:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
- name: Build Data Connector
working-directory: src/powerbi-data-connector
run: |
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
-11
View File
@@ -1,11 +0,0 @@
workflow: GitFlow/v1
next-version: 3.0.0
mode: ManualDeployment
branches:
main:
label: rc
develop:
regex: ^dev$
label: beta
unknown:
increment: None
+36
View File
@@ -75,6 +75,42 @@ shared Speckle.GetWorkspace = Value.ReplaceType(
type function (url as Uri.Type) as record
);
shared Speckle.Objects.Properties = Value.ReplaceType(
Speckle.LoadFunction("Objects.Properties.pqm"),
type function (inputRecord as record, optional filterKeys as list) as record
);
shared Speckle.Utils.ExpandRecord = Value.ReplaceType(
Speckle.LoadFunction("Utils.ExpandRecord.pqm"),
type function (
table as table,
columnName as text,
optional FieldNames as list,
optional UseCombinedNames as logical
) as table
);
shared Speckle.Objects.Collections = Value.ReplaceType(
Speckle.LoadFunction("Objects.Collections.pqm"),
type function (inputData as table) as table
);
shared Speckle.Objects.CompositeStructure = Value.ReplaceType(
Speckle.LoadFunction("Objects.CompositeStructure.pqm"),
type function (objectRecord as record, optional outputAsList as nullable logical) as any
);
shared Speckle.Objects.MaterialQuantities = Value.ReplaceType(
Speckle.LoadFunction("Objects.MaterialQuantities.pqm"),
type function (objectRecord as record, optional outputAsList as logical) as any
);
shared Speckle.Models.Federate = Value.ReplaceType(
Speckle.LoadFunction("Models.Federate.pqm"),
type function (tables as list, optional excludeData as logical) as table
);
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
@@ -39,8 +39,9 @@
),
// fields to remove from data record
FieldsToRemove = {"__closure", "totalChildrenCount", "renderMaterialProxies"},
// create the final table with cleaned data records
FinalTable = Table.FromRecords(
// create basic table with cleaned data records (no properties column yet)
BasicTable = Table.FromRecords(
List.Transform(
TableFromList[Column1],
each let
@@ -48,27 +49,68 @@
fieldsToRemoveForThisRecord = List.Select(
FieldsToRemove,
each Record.HasFields(record, {_})
)
),
cleanedRecord = Record.RemoveFields(record, fieldsToRemoveForThisRecord)
in
[
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Version Object ID" = rootId,
data = Record.RemoveFields(record, fieldsToRemoveForThisRecord) // Data
data = cleanedRecord // Data
]
)
),
// function to check if a row should be excluded based on speckle type
ShouldExcludeRow = (row as record) as logical =>
let
speckleType = Record.FieldOrDefault(row[data], "speckle_type", "")
in
speckleType = "Speckle.Core.Models.DataChunk" or
Text.Contains(speckleType, "Objects.Other.RawEncoding"),
// Filtering logic here
// If, model data contains any DataObject -> fetch only data objects
// If there are no data objects in the data -> fetch everything but DataChunks
// If model data contains any DataObject -> fetch only data objects (excluding unwanted types)
// If there are no data objects in the data -> fetch everything but exclude DataChunks and RawEncoding
// Check if model contains any DataObject
HasDataObjects = Table.RowCount(
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
Table.SelectRows(
BasicTable,
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
and not ShouldExcludeRow(_)
)
) > 0,
FilteredTable = if HasDataObjects then
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
// load the Objects.Properties function only if we have DataObjects
ObjectsProperties = if HasDataObjects then Extension.LoadFunction("Objects.Properties.pqm") else null,
// Add properties column only if model has DataObjects
FinalTable = if HasDataObjects then
Table.AddColumn(
BasicTable,
"properties",
each let
dataRecord = [data],
isDataObject = Text.Contains(Record.FieldOrDefault(dataRecord, "speckle_type", ""), "DataObject"),
hasProperties = Record.HasFields(dataRecord, {"properties"}),
extractedProperties = if hasProperties and isDataObject then
try ObjectsProperties(dataRecord) otherwise []
else
[]
in
if Record.FieldCount(extractedProperties) > 0 then extractedProperties else null
)
else
Table.SelectRows(FinalTable, each Record.FieldOrDefault([data], "speckle_type", "") <> "Speckle.Core.Models.DataChunk")
BasicTable,
// Apply the same filtering logic as before
FilteredTable = if HasDataObjects then
Table.SelectRows(
FinalTable,
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
and not ShouldExcludeRow(_)
)
else
Table.SelectRows(FinalTable, each not ShouldExcludeRow(_))
in
FilteredTable
@@ -39,29 +39,38 @@
projectResult = ApiFetch(server, projectQuery, projectVariables),
workspaceId = projectResult[data][workspaceId],
// query to get workspace
workspaceQuery = "query Workspace($workspaceId: String!, $featureName: WorkspaceFeatureName!) {
data:workspace(id: $workspaceId) {
logo
name
hasAccessToFeature(featureName: $featureName)
}
}",
workspaceVariables = [
workspaceId = workspaceId,
featureName = "hideSpeckleBranding"
],
workspaceResult = ApiFetch(server, workspaceQuery, workspaceVariables),
// check if workspaceId is null (personal project)
workspaceInfo = if workspaceId = null then
[
workspaceId = null,
workspaceLogo = null,
workspaceName = null,
canHideBranding = false
]
else
// query workspace only if workspaceId exists
let
workspaceQuery = "query Workspace($workspaceId: String!, $featureName: WorkspaceFeatureName!) {
data:workspace(id: $workspaceId) {
logo
name
hasAccessToFeature(featureName: $featureName)
}
}",
workspace = workspaceResult[data],
workspaceInfo = [
workspaceId = workspaceId,
workspaceLogo = workspace[logo],
workspaceName = workspace[name],
canHideBranding = workspace[hasAccessToFeature]
]
workspaceVariables = [
workspaceId = workspaceId,
featureName = "hideSpeckleBranding"
],
workspaceResult = ApiFetch(server, workspaceQuery, workspaceVariables),
workspace = workspaceResult[data]
in
[
workspaceId = workspaceId,
workspaceLogo = workspace[logo],
workspaceName = workspace[name],
canHideBranding = workspace[hasAccessToFeature]
]
in
workspaceInfo
@@ -0,0 +1,30 @@
// function for federating multiple tables by combining them and creating a concatenated Version Object ID
(tables as list, optional excludeData as logical) as table =>
let
ViewerOnly = if excludeData = null then false else excludeData,
// filter columns from each table if excludeData is true
ProcessedTables = List.Transform(
tables,
each
if ViewerOnly then
Table.SelectColumns(_, {"Version Object ID", "Object IDs"}, MissingField.Ignore)
else
_
),
CombinedTable = Table.Combine(ProcessedTables),
DistinctVersionObjectIDs = List.Distinct(CombinedTable[Version Object ID]),
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
// Replace all Version Object ID values with the concatenated string
FederatedTable = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
{"Version Object ID"}
)
in
FederatedTable
@@ -0,0 +1,163 @@
// function for mapping collection names to referenced elements in Speckle data
(inputData as table) as table =>
let
// Helper function to safely get field value
SafeFieldValue = (record as record, fieldName as text) as any =>
if Record.HasFields(record, {fieldName}) then
Record.Field(record, fieldName)
else
null,
// Helper function to safely get nested field value
SafeNestedValue = (record as record, path as list) as any =>
List.Accumulate(
path,
record,
(current, fieldName) =>
if current <> null and Value.Is(current, type record) and Record.HasFields(current, {fieldName}) then
Record.Field(current, fieldName)
else
null
),
// Step 1: Identify Collection Objects
CollectionObjects = Table.SelectRows(
inputData,
each
let
speckleType = SafeFieldValue(_, "Speckle Type")
in
speckleType <> null and Text.Contains(speckleType, "Collection")
),
// Step 2: Extract Collection Metadata
CollectionMetadata = Table.AddColumn(
CollectionObjects,
"CollectionInfo",
each
let
objectId = SafeFieldValue(_, "Object IDs"),
collectionName = SafeNestedValue(_, {"data", "name"}),
elements = SafeNestedValue(_, {"data", "elements"})
in
[
ObjectId = objectId,
CollectionName = if collectionName <> null then collectionName else "Unnamed Collection",
Elements = if elements <> null and Value.Is(elements, type list) then elements else {}
]
),
// Step 3: Build Collection Hierarchy Mapping
CollectionHierarchy = Table.AddColumn(
CollectionMetadata,
"CollectionReferences",
each
let
info = [CollectionInfo],
collectionName = info[CollectionName],
elements = info[Elements]
in
List.Transform(
elements,
(element) =>
let
referencedId = if Value.Is(element, type record) and Record.HasFields(element, {"referencedId"}) then
element[referencedId]
else
null
in
if referencedId <> null then
[
ReferencedId = referencedId,
CollectionName = collectionName,
ParentCollectionId = info[ObjectId]
]
else
null
)
),
// Step 4: Flatten Reference Mapping
FlattenedReferences = Table.SelectRows(
Table.ExpandListColumn(
Table.SelectColumns(CollectionHierarchy, {"CollectionReferences"}),
"CollectionReferences"
),
each [CollectionReferences] <> null
),
ReferenceTable = Table.ExpandRecordColumn(
FlattenedReferences,
"CollectionReferences",
{"ReferencedId", "CollectionName", "ParentCollectionId"},
{"ReferencedId", "CollectionName", "ParentCollectionId"}
),
// Step 5: Build Hierarchical Collection Paths
BuildCollectionPath = (objectId as text, visited as list) as text =>
let
// Prevent infinite loops
_ = if List.Contains(visited, objectId) then
error "Circular reference detected in collection hierarchy"
else
null,
newVisited = List.InsertRange(visited, 0, {objectId}),
// Find if this object is referenced by any collection
parentReferences = Table.SelectRows(ReferenceTable, each [ReferencedId] = objectId),
result = if Table.RowCount(parentReferences) = 0 then
// No parent collection found
""
else
let
parentRef = parentReferences{0},
parentCollectionId = parentRef[ParentCollectionId],
currentCollectionName = parentRef[CollectionName],
// Recursively get parent path
parentPath = @BuildCollectionPath(parentCollectionId, newVisited),
// Build full path
fullPath = if parentPath = "" then
currentCollectionName
else
parentPath & "::" & currentCollectionName
in
fullPath
in
result,
// Step 6: Add Collection Paths to data field
FinalData = Table.TransformColumns(
inputData,
{
"data", each
let
currentData = _,
currentRow = Table.SelectRows(inputData, each [data] = currentData){0},
objectId = SafeFieldValue(currentRow, "Object IDs"),
collectionPath = if objectId <> null then
try
BuildCollectionPath(objectId, {})
otherwise
""
else
"",
// Add CollectionPath field to the data record, set to null if empty
enhancedData = if Value.Is(currentData, type record) then
Record.AddField(
currentData,
"collectionPath",
if collectionPath = "" then null else collectionPath
)
else
currentData
in
enhancedData
}
)
in
FinalData
@@ -0,0 +1,18 @@
(objectRecord as record, optional outputAsList as nullable logical) as any =>
let
compositeStructure =
if Record.HasFields(objectRecord[properties], "Composite Structure") then
objectRecord[properties][Composite Structure]
else if Record.HasFields(objectRecord[properties], "Parameters") and
Record.HasFields(objectRecord[properties][Parameters], "Type Parameters") and
Record.HasFields(objectRecord[properties][Parameters][Type Parameters], "Structure") then
objectRecord[properties][Parameters][Type Parameters][Structure]
else
null,
result =
if outputAsList = true then
if compositeStructure <> null then Record.ToList(compositeStructure) else null
else
compositeStructure
in
result
@@ -0,0 +1,15 @@
// Helper function to extract [properties][Material Quantities] and optionally output as list
(objectRecord as record, optional outputAsList as logical) as any =>
let
// Ensure outputAsList is logical and defaults to false if not provided
OutputAsList = if outputAsList = null then false else outputAsList,
// Check if 'properties' and 'Material Quantities' exist
HasMaterialQuantities = Record.HasFields(objectRecord, {"properties"}) and Record.HasFields(Record.Field(objectRecord, "properties"), {"Material Quantities"}),
MaterialQuantities = if HasMaterialQuantities then Record.Field(Record.Field(objectRecord, "properties"), "Material Quantities") else null,
Result = if MaterialQuantities = null then null else
if OutputAsList then
Record.ToList(MaterialQuantities)
else
MaterialQuantities
in
Result
@@ -0,0 +1,196 @@
// function for extracting and flattening properties from Speckle objects
(inputRecord as record, optional filterKeys as list) as record =>
let
// auto-extract properties if the input has a "properties" field
ActualInput = if Record.HasFields(inputRecord, {"properties"}) then
inputRecord[properties]
else
inputRecord,
// helper function to check if a key should be included
ShouldIncludeKey = (keyName as text) as logical =>
if filterKeys = null then
true
else
List.Contains(filterKeys, keyName),
// define excluded paths
ExcludedPaths = {
"Composite Structure",
"Material Quantities",
"Parameters.Type Parameters.Structure"
},
IsExcludedPath = (path as text) as logical =>
List.AnyTrue(
List.Transform(
ExcludedPaths,
(excludedPath) => Text.StartsWith(path, excludedPath)
)
),
// helper function to handle duplicate keys by adding suffixes
AddUniqueKey = (existingRecord as record, newKey as text, newValue as any) as record =>
let
originalKey = newKey,
counter = 1,
// find a unique key by adding suffix if needed
FindUniqueKey = (testKey as text, testCounter as number) as text =>
if Record.HasFields(existingRecord, {testKey}) then
@FindUniqueKey(originalKey & "_" & Text.From(testCounter), testCounter + 1)
else
testKey,
uniqueKey = FindUniqueKey(newKey, counter),
result = Record.AddField(existingRecord, uniqueKey, newValue)
in
result,
// enhanced combine function that handles duplicates and filtering
SafeRecordCombine = (records as list) as record =>
List.Accumulate(
records,
[],
(state, current) =>
List.Accumulate(
Record.FieldNames(current),
state,
(innerState, fieldName) =>
if ShouldIncludeKey(fieldName) then
AddUniqueKey(innerState, fieldName, Record.Field(current, fieldName))
else
innerState
)
),
// helper function to process name-value objects
ProcessNameValueObject = (obj as record) as record =>
let
// Assert that the object has required fields
_ = if not (Record.HasFields(obj, {"name"}) and Record.HasFields(obj, {"value"})) then
error [
Reason = "Invalid Name-Value Object",
Message = "Object must have both 'name' and 'value' fields"
]
else
null,
BaseName = obj[name],
BaseValue = obj[value],
// only extract name and value if it should be included
Result = if ShouldIncludeKey(BaseName) then
Record.FromList({BaseValue}, {BaseName})
else
[]
in
Result,
// helper function to process direct key-value objects
ProcessDirectKeyValueObject = (obj as record) as record =>
let
// assert that input is a record
_ = if not Value.Is(obj, type record) then
error [
Reason = "Invalid Input Type",
Message = "Expected record for direct key-value processing"
]
else
null,
// extract all primitive key-value pairs
PrimitiveFields = List.Transform(
Record.FieldNames(obj),
(fieldName) =>
let
fieldValue = Record.Field(obj, fieldName)
in
if not Type.Is(Value.Type(fieldValue), type record) and ShouldIncludeKey(fieldName) then
Record.FromList({fieldValue}, {fieldName})
else
[]
),
// filter out empty records and combine using safe combine
FilteredFields = List.Select(PrimitiveFields, (rec) => Record.FieldCount(rec) > 0),
Result = SafeRecordCombine(FilteredFields)
in
Result,
// helper functions for type checking
HasDirectKeyValuePattern = (obj as record) as logical =>
List.AllTrue(
List.Transform(
Record.FieldValues(obj),
(value) => not Type.Is(Value.Type(value), type record)
)
),
IsNestedContainer = (obj as record) as logical =>
List.AnyTrue(
List.Transform(
Record.FieldValues(obj),
(value) => Type.Is(Value.Type(value), type record)
)
),
// main processing function with path tracking
ProcessField = (fieldName as text, fieldValue as any, currentPath as text) as record =>
let
fieldPath = if currentPath = "" then fieldName else currentPath & "." & fieldName
in
if IsExcludedPath(fieldPath) then
[]
else if Type.Is(Value.Type(fieldValue), type record) then
if Record.HasFields(fieldValue, {"name", "value"}) then
ProcessNameValueObject(fieldValue)
else if HasDirectKeyValuePattern(fieldValue) then
ProcessDirectKeyValueObject(fieldValue)
else if IsNestedContainer(fieldValue) then
// recursive call for nested containers
@ProcessRecord(fieldValue, fieldPath)
else
// unknown record type, skip
[]
else
// primitive value - check if should be included
if ShouldIncludeKey(fieldName) then
Record.FromList({fieldValue}, {fieldName})
else
[],
// process entire record recursively
ProcessRecord = (record as record, currentPath as text) as record =>
let
// assert that input is a record
_ = if not Value.Is(record, type record) then
error [
Reason = "Invalid Record Type",
Message = "Expected record for processing, but received: " & Text.From(Value.Type(record))
]
else
null,
ProcessedFields = List.Transform(
Record.FieldNames(record),
(fieldName) => ProcessField(fieldName, Record.Field(record, fieldName), currentPath)
),
FilteredFields = List.Select(ProcessedFields, (rec) => Record.FieldCount(rec) > 0),
CombinedRecord = SafeRecordCombine(FilteredFields)
in
CombinedRecord,
// assert that input is a record before processing
_ = if not Value.Is(ActualInput, type record) then
error [
Reason = "Invalid Input Type",
Message = "Input must be a record, but received: " & Text.From(Value.Type(ActualInput))
]
else
null,
// start processing from root
Result = ProcessRecord(ActualInput, "")
in
Result
@@ -1,5 +1,5 @@
(url as text) as list =>
let
try let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
@@ -70,4 +70,10 @@
JsonResponse = Json.Document(Response)
in
JsonResponse
JsonResponse
otherwise
error [
Reason = "Desktop Service Not Available",
Message = "Cannot connect to Speckle Desktop Service. Please ensure the Desktop Service is running and try again.",
Detail = "The Speckle Desktop Service must be running to load data from Speckle. Please start the Desktop Service application and refresh your data connection."
]
@@ -0,0 +1,31 @@
// Expands a record column in a table, adding new columns for each field in the record.
// If UseCombinedNames is true, columns are named as ColumnName.FieldName, otherwise just FieldName.
// If FieldNames is provided (list), only those fields are expanded.
(table as table, columnName as text, optional FieldNames as list, optional UseCombinedNames as logical) as table =>
let
useCombined = if UseCombinedNames = null then false else UseCombinedNames,
// Determine which field names to expand
allFieldNames = if FieldNames <> null then FieldNames else List.Distinct(
List.Combine(
List.Transform(
Table.Column(table, columnName),
each if _ is record then Record.FieldNames(_) else {}
)
)
),
// Add each field as a new column
addColumns = List.Accumulate(
allFieldNames,
table,
(state, field) =>
Table.AddColumn(
state,
if useCombined then columnName & "." & field else field,
(row) =>
if Record.HasFields(row, columnName) and Record.Field(row, columnName) is record and Record.HasFields(Record.Field(row, columnName), field)
then Record.Field(Record.Field(row, columnName), field)
else null
)
)
in
addColumns
+16 -21
View File
@@ -78,10 +78,20 @@
}
}
},
"workspace": {
"properties": {
"brandingHidden": {
"type": { "bool": true }
}
}
},
"viewMode": {
"properties": {
"defaultViewMode": {
"type": { "text": true }
},
"navbarHidden": {
"type": { "bool": true }
}
}
},
@@ -90,29 +100,14 @@
"defaultView": {
"type": { "text": true }
},
"allowCameraUnder": {
"type": {
"bool": true
}
"isOrtho": {
"type": { "bool": true }
},
"zoomOnDataChange": {
"type": {
"bool": true
}
"isGhost": {
"type": { "bool": true }
},
"projection": {
"type": {
"enumeration": [
{
"displayName": "Perspective",
"value": "perspective"
},
{
"displayName": "Orthographic",
"value": "orthographic"
}
]
}
"zoomOnFilter": {
"type": { "bool": true }
}
}
},
+11 -8
View File
@@ -31,7 +31,8 @@
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
"regenerator-runtime": "^0.13.11"
"regenerator-runtime": "^0.13.11",
"vue-tippy": "^6.7.1"
},
"devDependencies": {
"@babel/core": "^7.21.8",
@@ -5366,9 +5367,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001689",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz",
"integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==",
"version": "1.0.30001726",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"funding": [
{
"type": "opencollective",
@@ -5382,7 +5383,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.1.2",
@@ -14714,9 +14716,10 @@
}
},
"node_modules/vue-tippy": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/vue-tippy/-/vue-tippy-6.5.0.tgz",
"integrity": "sha512-U44UDETTLuZWZGosagslEwgimWQdt1JVSxfWStVPnVdeqo2jo9X5zW3SB04k7JaTFosdgrDhFsUDrd6n42Nh7Q==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/vue-tippy/-/vue-tippy-6.7.1.tgz",
"integrity": "sha512-gdHbBV5/Vc8gH87hQHLA7TN1K4BlLco3MAPrTb70ZYGXxx+55rAU4a4mt0fIoP+gB3etu1khUZ6c29Br1n0CiA==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
+2 -1
View File
@@ -35,7 +35,8 @@
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
"regenerator-runtime": "^0.13.11"
"regenerator-runtime": "^0.13.11",
"vue-tippy": "^6.7.1"
},
"devDependencies": {
"@babel/core": "^7.21.8",
+25
View File
@@ -1,4 +1,19 @@
<template>
<div
v-if="visualStore.loadingProgress"
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-50 text-center text-sm"
>
<!-- Progress Bar -->
<LoadingBar :progress="visualStore.loadingProgress"></LoadingBar>
</div>
<div
v-if="visualStore.commonError"
class="absolute top-11 left-1/2 -translate-x-1/2 z-100 bg-white bg-opacity-70 text-black text-center text-sm px-4 py-1 rounded shadow font-medium cursor-default"
>
{{ visualStore.commonError }}
</div>
<ViewerView v-if="visualStore.isViewerReadyToLoad" />
<HomeView v-else />
</template>
@@ -8,6 +23,7 @@ import HomeView from './views/HomeView.vue'
import ViewerView from './views/ViewerView.vue'
import { onMounted } from 'vue'
import { useVisualStore } from './store/visualStore'
import LoadingBar from '@src/components/loading/LoadingBar.vue'
const visualStore = useVisualStore()
@@ -15,3 +31,12 @@ onMounted(() => {
console.log('App mounted')
})
</script>
<style>
.tippy-box[data-theme~='custom'] {
font-size: 10px;
padding: 0px 0px;
border-radius: 4px;
text-align: center;
}
</style>
@@ -2,12 +2,33 @@
<div class="space-y-2">
<ViewerControlsButtonGroup>
<!-- Zoom extend -->
<ViewerControlsButtonToggle v-tippy="'Zoom extends'" flat @click="onZoomExtentsClicked">
<ViewerControlsButtonToggle flat tooltip="Zoom extends" @click="onZoomExtentsClicked">
<ArrowsPointingOutIcon class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Zoom on Filter -->
<ViewerControlsButtonToggle
:tooltip="
visualStore.isZoomOnFilterActive
? 'Move camera on filter'
: 'Keep camera position on filter'
"
flat
@click="toggleZoomOnFilter"
>
<ZoomToFit v-if="visualStore.isZoomOnFilterActive" class="h-5 w-5" />
<ZoomToFit v-else class="h-5 w-5 opacity-30" />
</ViewerControlsButtonToggle>
<!-- Ghost / Hidden -->
<ViewerControlsButtonToggle flat @click="toggleGhostHidden">
<Ghost v-if="isGhost" class="h-5 w-5" />
<ViewerControlsButtonToggle
:tooltip="
visualStore.isGhostActive
? 'Hide ghosted objects on filter'
: 'Show ghosted objects on filter'
"
flat
@click="toggleGhostHidden"
>
<Ghost v-if="visualStore.isGhostActive" class="h-5 w-5" />
<Ghost v-else class="h-5 w-5 opacity-30" />
</ViewerControlsButtonToggle>
</ViewerControlsButtonGroup>
@@ -31,10 +52,11 @@
<ViewerControlsButtonToggle
flat
secondary
:active="isOrthoProjection"
tooltip="Projection"
:active="visualStore.isOrthoProjection"
@click="toggleProjection"
>
<Perspective v-if="isOrthoProjection" class="h-3.5 md:h-4 w-4" />
<Perspective v-if="visualStore.isOrthoProjection" class="h-3.5 md:h-4 w-4" />
<PerspectiveMore v-else class="h-3.5 md:h-4 w-4" />
</ViewerControlsButtonToggle>
</ViewerControlsButtonGroup>
@@ -56,6 +78,7 @@ import Perspective from '../components/global/icon/Perspective.vue'
import PerspectiveMore from '../components/global/icon/PerspectiveMore.vue'
import Ghost from '../components/global/icon/Ghost.vue'
import ZoomToFit from '../components/global/icon/ZoomToFit.vue'
const visualStore = useVisualStore()
@@ -70,9 +93,6 @@ withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false
})
const isOrthoProjection = ref(false)
const isGhost = ref(true)
type ActiveControl =
| 'none'
| 'viewModes'
@@ -94,13 +114,20 @@ const toggleActiveControl = (control: ActiveControl) => {
}
const toggleProjection = () => {
isOrthoProjection.value = !isOrthoProjection.value
visualStore.viewerEmit('toggleProjection')
visualStore.setIsOrthoProjection(!visualStore.isOrthoProjection)
visualStore.writeIsOrthoToFile()
}
const toggleGhostHidden = () => {
isGhost.value = !isGhost.value
visualStore.viewerEmit('toggleGhostHidden', isGhost.value)
visualStore.setIsGhost(!visualStore.isGhostActive)
visualStore.viewerEmit('toggleGhostHidden', visualStore.isGhostActive)
visualStore.writeIsGhostToFile()
}
const toggleZoomOnFilter = () => {
visualStore.setIsZoomOnFilterActive(!visualStore.isZoomOnFilterActive)
visualStore.writeZoomOnFilterToFile()
}
const viewModesOpen = computed({
@@ -1,76 +1,130 @@
<template>
<transition name="slide-fade">
<nav
v-show="!isNavbarCollapsed"
class="fixed top-0 h-9 flex items-center bg-foundation border-b border-outline-2 w-full transition z-20 shadow-sm hover:shadow cursor-default"
>
<div class="flex items-center transition-all justify-between w-full">
<div class="flex items-center hover:cursor-pointer" @click="goToSpeckleWebsite">
<div class="max-[200px]:hidden block ml-2">
<img class="w-6 h-auto ml-1 mr-2 my-1" src="@assets/logo-big.png" />
</div>
<div class="font-sans font-medium">Speckle</div>
</div>
<div class="flex items-center">
<div class="font-thin text-xs mr-2 text-gray-400">v1.0.0</div>
<button
class="text-gray-400 hover:text-gray-700 transition"
title="Hide navbar"
@click="isNavbarCollapsed = true"
<div class="border">
<transition name="slide-fade">
<nav
v-show="!visualStore.isNavbarHidden"
class="fixed top-0 h-9 flex items-center bg-foundation border border-outline-2 w-full transition z-20 cursor-default"
>
<div class="flex items-center transition-all justify-between w-full">
<div
v-if="visualStore.receiveInfo.workspaceName"
class="flex items-center gap-2 p-0.5 pr-1.5 hover:bg-highlight-2 rounded ml-2"
>
<ChevronUpIcon class="w-4 h-4" />
</button>
<WorkspaceAvatar
:name="visualStore.receiveInfo.workspaceName"
:logo="visualStore.receiveInfo.workspaceLogo"
></WorkspaceAvatar>
<div class="min-w-0 truncate flex-grow text-left text-xs">
<span>{{ visualStore.receiveInfo.workspaceName }}</span>
</div>
</div>
<div v-else>
<div class="flex items-center hover:cursor-pointer" @click="goToSpeckleWebsite">
<div class="max-[200px]:hidden block ml-2">
<img class="w-6 h-auto ml-1 mr-2 my-1" src="@assets/logo-big.png" />
</div>
<div class="font-sans font-medium">Speckle</div>
</div>
</div>
<div class="flex items-center space-x-2">
<FormButton
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate"
v-tippy="{
content: 'New connector version is available.<br>Click to download.',
allowHTML: true
}"
color="outline"
size="sm"
@click="visualStore.downloadLatestVersion"
>
Update
</FormButton>
<div class="font-thin text-xs text-gray-400">
v{{ visualStore.receiveInfo.version }}
</div>
<button
class="text-gray-400 hover:text-gray-700 transition"
title="Hide navbar"
@click="visualStore.toggleNavbar()"
>
<ChevronUpIcon class="w-4 h-4" />
</button>
</div>
</div>
</div>
</nav>
</transition>
</nav>
</transition>
<!-- TODO: another transition here needed that below components - but this time it will move to left -->
<div
v-if="!isInteractive"
class="absolute top-1 left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-xs px-4 py-1 rounded shadow font-medium"
>
<strong>Object IDs</strong>
field is needed for interactivity with other visuals.
</div>
<div v-if="isNavbarCollapsed" class="fixed top-2 right-0 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@click="isNavbarCollapsed = false"
<div
v-if="!isInteractive"
class="absolute left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-xs px-4 py-1 rounded shadow font-medium cursor-default transition-all duration-300"
:class="visualStore.isNavbarHidden ? 'top-1' : 'top-11'"
>
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
</button>
</div>
<strong>Object IDs</strong>
field is needed for interactivity with other visuals.
</div>
<!-- till here -->
<div v-if="visualStore.isNavbarHidden" class="fixed top-0 right-0 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@click="visualStore.toggleNavbar()"
>
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
</button>
</div>
<transition name="slide-left">
<ViewerControls
v-show="!isNavbarCollapsed"
v-model:section-box="bboxActive"
:views="views"
class="fixed top-11 left-1 z-30"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
<transition name="slide-left">
<ViewerControls
v-show="!visualStore.isNavbarHidden"
v-model:section-box="bboxActive"
:views="views"
class="fixed top-11 left-2 z-30"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
/>
</transition>
<div v-if="visualStore.isFilterActive" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50">
<FormButton size="sm" @click="visualStore.resetFilters(), selectionHandler.reset()">
Reset filters
</FormButton>
</div>
<div
class="absolute z-10 flex items-center text-xs cursor-pointer"
:class="visualStore.isBrandingHidden ? 'bottom-0 right-0' : 'bottom-2 right-2'"
@click.stop="goToSpeckleWebsite"
>
<!-- TODO: fade bottom here as transition -->
<transition name="fade-bottom">
<div
v-if="!visualStore.isBrandingHidden"
class="flex items-center justify-center font-thin"
>
<div class="">Powered by</div>
<img class="w-4 h-auto mx-1" src="@assets/logo-big.png" />
<div class="font-medium">Speckle</div>
</div>
</transition>
<button
v-if="visualStore.receiveInfo && visualStore.receiveInfo.canHideBranding"
class="transition opacity-50 hover:opacity-100 ml-1"
:title="visualStore.isBrandingHidden ? '' : 'Hide branding'"
@click.stop="visualStore.toggleBranding()"
>
<ChevronUpIcon v-if="visualStore.isBrandingHidden" class="w-4 h-4 text-gray-400" />
<ChevronDownIcon v-else class="w-4 h-4" />
</button>
</div>
<div
ref="container"
class="fixed h-full w-full z-0 cursor-default"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
</transition>
<div v-if="visualStore.isFilterActive" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50">
<FormButton size="sm" @click="visualStore.resetFilters(), selectionHandler.reset()">
Reset filters
</FormButton>
</div>
<div
ref="container"
class="fixed h-full w-full z-0"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
</template>
<script async setup lang="ts">
@@ -84,6 +138,7 @@ import { useVisualStore } from '@src/store/visualStore'
import { ViewerHandler } from '@src/plugins/viewer'
import { selectionHandlerKey, tooltipHandlerKey } from '@src/injectionKeys'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline'
import WorkspaceAvatar from './workspace/WorkspaceAvatar.vue'
const visualStore = useVisualStore()
const { dragged } = useClickDragged()
@@ -97,8 +152,6 @@ const container = ref<HTMLElement>()
let bboxActive = ref(false)
let views: Ref<SpeckleView[]> = ref([])
const isNavbarCollapsed = ref(false)
const isInteractive = computed(
() => visualStore.fieldInputState.rootObjectId && visualStore.fieldInputState.objectIds
)
@@ -181,4 +234,19 @@ async function onCanvasAuxClick(ev: MouseEvent) {
opacity: 0;
transform: translateX(-20px);
}
.fade-bottom-enter-active,
.fade-bottom-leave-active {
transition: all 0.3s ease;
}
.fade-bottom-enter-from,
.fade-bottom-leave-to {
opacity: 0;
transform: translateY(10px);
}
.fade-bottom-enter-to,
.fade-bottom-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>
@@ -0,0 +1,24 @@
<template>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.75 3.75V8.25M3.75 3.75H8.25M3.75 3.75L9 9M20.25 3.75H15.75M20.25 3.75V8.25M20.25 3.75L15 9"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15.75 15.4028L18.8093 12.3435C18.8772 12.2756 18.9638 12.2294 19.0581 12.2107C19.1523 12.1919 19.25 12.2016 19.3387 12.2383C19.4275 12.2751 19.5034 12.3373 19.5568 12.4172C19.6102 12.4971 19.6388 12.591 19.6389 12.687V20.063C19.6388 20.159 19.6102 20.2529 19.5568 20.3328C19.5034 20.4127 19.4275 20.4749 19.3387 20.5117C19.25 20.5484 19.1523 20.5581 19.0581 20.5393C18.9638 20.5206 18.8772 20.4744 18.8093 20.4065L15.75 17.3472M8.45833 20.75H14.2917C14.6784 20.75 15.0494 20.5964 15.3229 20.3229C15.5964 20.0494 15.75 19.6784 15.75 19.2917V13.4583C15.75 13.0716 15.5964 12.7006 15.3229 12.4271C15.0494 12.1536 14.6784 12 14.2917 12H8.45833C8.07156 12 7.70063 12.1536 7.42714 12.4271C7.15365 12.7006 7 13.0716 7 13.4583V19.2917C7 19.6784 7.15365 20.0494 7.42714 20.3229C7.70063 20.5964 8.07156 20.75 8.45833 20.75Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -1,22 +1,46 @@
<template>
<div
:class="[
'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl',
showBar ? 'opacity-100' : 'opacity-0'
]"
>
<div class="swoosher relative top-0 bg-blue-500/50"></div>
<div class="w-full text-xs text-foreground-on-primary space-y-1">
<!-- Bar container -->
<div
:class="[
'w-full h-1 overflow-hidden rounded-xl bg-blue-500/30',
showBar ? 'opacity-100' : 'opacity-0'
]"
>
<!-- Swooshing animation -->
<div v-if="isIndeterminate" class="swoosher top-0 left-0 h-full bg-blue-500/50"></div>
<!-- Determinate progress bar -->
<div
v-else
class="top-0 left-0 h-full bg-blue-500 transition-all duration-300 ease-linear"
:style="{ width: `${progressPercent + 20}%` }"
></div>
</div>
<!-- Progress text below -->
<div v-if="isIndeterminate" class="text-[13px] text-center text-foreground-2">
{{ props.progress.summary }}
</div>
<div v-else class="text-[13px] text-center text-foreground-2">
{{ progressPercent.toFixed(0) }}% ({{ props.progress.summary }})
</div>
</div>
</template>
<script setup lang="ts">
import { useMounted } from '@vueuse/core'
import { computed } from 'vue'
const props = defineProps<{ loading: boolean; clientOnly?: boolean }>()
<script setup lang="ts">
import { computed } from 'vue'
import { useMounted } from '@vueuse/core'
import { LoadingProgress } from '@src/store/visualStore'
const props = defineProps<{ progress: LoadingProgress; clientOnly?: boolean }>()
const mounted = useMounted()
const showBar = computed(() => (mounted.value || !props.clientOnly) && props.loading)
const showBar = computed(() => (mounted.value || !props.clientOnly) && !!props.progress)
const isIndeterminate = computed(() => props.progress.progress == null)
const progressPercent = computed(() => (props.progress.progress ?? 0) * 100)
</script>
<style scoped>
.swoosher {
width: 100%;
@@ -29,11 +53,9 @@ const showBar = computed(() => (mounted.value || !props.clientOnly) && props.loa
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
@@ -1,5 +1,6 @@
<template>
<button
:title="tooltip"
:class="`transition rounded-lg w-8 md:w-10 h-8 md:h-10 shrink-0 flex items-center justify-center ${colorClasses} outline-none ${
props.flat ? '!w-7 md:!w-9' : 'border border-outline-2 w-8 md:w-10 shadow'
}`"
@@ -15,6 +16,7 @@ const props = defineProps<{
active?: boolean
flat?: boolean
secondary?: boolean
tooltip?: string
}>()
const colorClasses = computed(() => {
@@ -1,6 +1,6 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" tooltip="View modes">
<ViewerMenu v-model:open="open" title="View modes">
<template #trigger-icon>
<ViewModes class="h-5 w-5" />
</template>
@@ -1,6 +1,6 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" tooltip="Views">
<ViewerMenu v-model:open="open" title="Views">
<template #trigger-icon>
<Views class="w-5 h-5" />
</template>
@@ -1,7 +1,7 @@
<template>
<div class="flex shrink-0 overflow-hidden rounded-md border border-outline-2 bg-foundation-2">
<div
class="h-full w-full bg-cover bg-center bg-no-repeat flex items-center justify-center"
class="w-6 h-6 bg-center bg-contain bg-no-repeat flex items-center justify-center"
:style="logo ? { backgroundImage: `url('${logo}')` } : {}"
>
<span v-if="!logo" class="text-foreground-3 uppercase leading-none">
@@ -0,0 +1,55 @@
import { useVisualStore } from '@src/store/visualStore'
import { ref } from 'vue'
type Versions = {
Versions: Version[]
}
export type Version = {
Number: string
Url: string
Os: number
Architecture: number
Date: string
Prerelease: boolean
}
export function useUpdateConnector() {
const versions = ref<Version[]>([])
const latestAvailableVersion = ref<Version | null>(null)
async function checkUpdate() {
try {
await getVersions()
} catch (e) {
console.error(e)
}
}
async function getVersions() {
const visualStore = useVisualStore()
const response = await fetch(`https://releases.speckle.dev/manager2/feeds/powerbi-v3.json`, {
method: 'GET'
})
if (!response.ok) {
throw new Error('Failed to fetch versions')
}
const data = (await response.json()) as unknown as Versions
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
const sanitizedVersion = sanitizeVersion(sortedVersions[0].Number)
latestAvailableVersion.value = { ...sortedVersions[0], Number: sanitizedVersion }
visualStore.setLatestAvailableVersion(latestAvailableVersion.value)
}
function sanitizeVersion(version: string): string {
const match = version.match(/\d+\.\d+\.\d+/)
return match ? match[0] : version // fallback to original version
}
return { checkUpdate }
}
+56 -23
View File
@@ -11,7 +11,9 @@ import {
Viewer,
HybridCameraController,
SelectionExtension,
FilteringExtension
FilteringExtension,
UpdateFlags,
ViewerEvent
} from '@speckle/viewer'
import { SpeckleObjectsOfflineLoader } from '@src/laoder/SpeckleObjectsOfflineLoader'
import { useVisualStore } from '@src/store/visualStore'
@@ -35,8 +37,8 @@ export interface Hit {
export interface IViewerEvents {
ping: (message: string) => void
setSelection: (objectIds: string[]) => void
resetFilter: (objectIds: string[]) => void
filterSelection: (objectIds: string[], ghost: boolean) => void
resetFilter: (objectIds: string[], ghost: boolean, zoom: boolean) => void
filterSelection: (objectIds: string[], ghost: boolean, zoom: boolean) => void
setViewMode: (viewMode: ViewMode) => void
colorObjectsByGroup: (
colorById: {
@@ -50,6 +52,7 @@ export interface IViewerEvents {
toggleProjection: () => void
toggleGhostHidden: (ghost: boolean) => void
loadObjects: (objects: object[]) => void
objectsLoaded: () => void
}
export type ColorBy = {
@@ -79,6 +82,7 @@ export class ViewerHandler {
this.emitter.on('zoomExtends', this.zoomExtends)
this.emitter.on('zoomObjects', this.zoomObjects)
this.emitter.on('loadObjects', this.loadObjects)
this.emitter.on('objectsLoaded', this.handleObjectsLoaded)
this.emitter.on('toggleProjection', this.toggleProjection)
this.emitter.on('toggleGhostHidden', this.toggleGhostHidden)
}
@@ -89,15 +93,14 @@ export class ViewerHandler {
this.filtering = this.viewer.getExtension(FilteringExtension)
this.selection = this.viewer.getExtension(SelectionExtension)
// NOTE: storing camera position into file triggers `update` function. even if I early return according to flag - it slows down the usage a lot.
// this.cameraControls.on(CameraEvent.Stationary, () => {
// console.log('🎬 Storing the camera position into file')
// const cameraController = this.viewer.getExtension(CameraController)
// const position = cameraController.getPosition()
// const target = cameraController.getTarget()
// const store = useVisualStore()
// store.writeCameraPositionToFile(position, target)
// })
const store = useVisualStore()
if (store.isOrthoProjection) {
this.cameraControls.toggleCameras()
}
this.viewer.on(ViewerEvent.LoadComplete, (arg: string) => {
store.clearLoadingProgress()
})
}
emit<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>): void {
@@ -109,10 +112,16 @@ export class ViewerHandler {
this.cameraControls.setCameraView(objectIds, animate)
}
public zoomExtends = () => this.cameraControls.setCameraView(undefined, false)
public zoomExtends = () => {
this.cameraControls.setCameraView(undefined, true)
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
}
public toggleProjection = () => this.cameraControls.toggleCameras()
public setView = (view: CanonicalView) => this.cameraControls.setCameraView(view, false)
public setView = (view: CanonicalView) => {
this.cameraControls.setCameraView(view, false)
this.snapshotCameraPositionAndStore()
}
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
// TODO
@@ -124,6 +133,15 @@ export class ViewerHandler {
viewModes.setViewMode(viewMode)
}
public snapshotCameraPositionAndStore = () => {
console.log('🎬 Storing the camera position into file')
const cameraController = this.viewer.getExtension(CameraController)
const position = cameraController.getPosition()
const target = cameraController.getTarget()
const store = useVisualStore()
store.writeCameraPositionToFile(position, target)
}
public selectObjects = (objectIds: string[]) => {
console.log('🔗 Handling setSelection inside ViewerHandler:', objectIds)
if (objectIds) {
@@ -131,20 +149,24 @@ export class ViewerHandler {
}
}
public filterSelection = (objectIds: string[], ghost: boolean) => {
public filterSelection = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
console.log('🔗 Handling filterSelection inside ViewerHandler')
if (objectIds) {
this.unIsolateObjects()
this.filteringState = this.filtering.isolateObjects(objectIds, 'powerbi', true, ghost)
this.zoomObjects(objectIds, true)
if (zoom) {
this.zoomObjects(objectIds, true)
}
}
}
public resetFilter = (objectIds: string[]) => {
public resetFilter = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
console.log('🔗 Handling filterSelection inside ViewerHandler')
if (objectIds) {
this.isolateObjects(objectIds, true)
this.zoomObjects(objectIds, true)
this.isolateObjects(objectIds, ghost)
if (zoom) {
this.zoomObjects(objectIds, true)
}
}
}
@@ -203,7 +225,8 @@ export class ViewerHandler {
const store = useVisualStore()
const speckleViews = []
modelObjects.forEach(async (objects) => {
// Use for...of loop to properly handle async operations
for (const objects of modelObjects) {
//@ts-ignore
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
@@ -216,7 +239,7 @@ export class ViewerHandler {
// Since you are setting another camera position, maybe you want the second argument to false
await this.viewer.loadObject(loader, true)
this.viewer.getRenderer().shadowcatcher.shadowcatcherMesh.visible = false // works fine only right after loadObjects
})
}
store.setSpeckleViews(speckleViews)
if (store.defaultViewModeInFile) {
@@ -225,9 +248,9 @@ export class ViewerHandler {
Tracker.dataLoaded({
sourceHostApp: store.receiveInfo.sourceApplication,
workspace_id: store.receiveInfo.workspaceId
workspace_id: store.receiveInfo.workspaceId,
core_version: store.receiveInfo.version
})
// camera need to be set after objects loaded
if (store.cameraPosition) {
const position = new Vector3(
store.cameraPosition[0],
@@ -241,12 +264,22 @@ export class ViewerHandler {
)
this.cameraControls.setCameraView({ position, target }, true)
}
// Emit objects loaded event to trigger update
this.emit('objectsLoaded')
}
private handlePing = (message: string) => {
console.log(message)
}
private handleObjectsLoaded = () => {
console.log('🎯 Objects loaded - triggering update')
const store = useVisualStore()
// Handle state restoration after objects are loaded
store.handleObjectsLoadedComplete()
}
private pickViewableHit(hits: Hit[]): Hit | null {
// The current filtering state
const filteringState = this.filtering.filteringState
+237 -27
View File
@@ -1,4 +1,5 @@
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
import { Version } from '@src/composables/useUpdateConnector'
import { ColorBy, IViewerEvents } from '@src/plugins/viewer'
import { SpeckleVisualSettingsModel } from '@src/settings/visualSettingsModel'
import { SpeckleDataInput } from '@src/types'
@@ -6,7 +7,7 @@ import { zipModelObjects } from '@src/utils/compression'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { defineStore } from 'pinia'
import { Vector3 } from 'three'
import { ref, shallowRef } from 'vue'
import { computed, ref, shallowRef } from 'vue'
export type InputState = 'valid' | 'incomplete' | 'invalid'
@@ -17,16 +18,27 @@ export type FieldInputState = {
tooltipData: boolean
}
export type LoadingProgress = { summary: string; progress: number; step?: string }
export const useVisualStore = defineStore('visualStore', () => {
const latestAvailableVersion = ref<Version | null>(null)
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
const formattingSettings = ref<SpeckleVisualSettingsModel>()
const loadingProgress = ref<{ summary: string; progress: number }>(undefined)
const loadingProgress = ref<LoadingProgress>(undefined)
const objectsFromStore = ref<object[]>(undefined)
const postFileSaveSkipNeeded = ref<boolean>(false)
const postClickSkipNeeded = ref<boolean>(false)
const isFilterActive = ref<boolean>(false)
const isBrandingHidden = ref<boolean>(false)
const isOrthoProjection = ref<boolean>(false)
const isGhostActive = ref<boolean>(true)
const isNavbarHidden = ref<boolean>(false)
const isZoomOnFilterActive = ref<boolean>(true)
const commonError = ref<string>(undefined)
// once you see this shit, you might freak out and you are right. All of them needed because of "update" function trigger by API.
// most of the time we need to know what we are doing to treat operations accordingly. Ask for more to me (Ogu), but the answers will make both of us unhappy.
@@ -73,6 +85,17 @@ export const useVisualStore = defineStore('visualStore', () => {
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
const setLatestAvailableVersion = (version: Version | null) => {
latestAvailableVersion.value = version
}
const isConnectorUpToDate = computed(() => {
if (receiveInfo.value && receiveInfo.value.version) {
return receiveInfo.value.version === latestAvailableVersion.value?.Number
}
return false
})
/**
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
* @param emit picky emit function to trigger events under `IViewerEvents` interface
@@ -101,7 +124,9 @@ export const useVisualStore = defineStore('visualStore', () => {
}
}
const clearLoadingProgress = () => (loadingProgress.value = undefined)
const clearLoadingProgress = () => {
loadingProgress.value = undefined
}
// MAKE TS HAPPY
type SpeckleObject = {
@@ -114,7 +139,6 @@ export const useVisualStore = defineStore('visualStore', () => {
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
await viewerEmit.value('loadObjects', objects)
clearLoadingProgress()
objectsFromStore.value = objects
isViewerObjectsLoaded.value = true
viewerReloadNeeded.value = false
@@ -133,19 +157,26 @@ export const useVisualStore = defineStore('visualStore', () => {
lastLoadedRootObjectId.value = modelIds
console.log(`🔄 Forcing viewer re-render for new root object id.`)
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
clearLoadingProgress()
viewerReloadNeeded.value = false
isViewerObjectsLoaded.value = true
setLoadingProgress('Storing objects into file', null)
writeObjectsToFile(dataInput.value.modelObjects)
loadingProgress.value = undefined
}
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, true)
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
viewerEmit.value('resetFilter', dataInput.value.objectIds)
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
}
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
@@ -185,6 +216,54 @@ export const useVisualStore = defineStore('visualStore', () => {
})
}
const writeIsOrthoToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
isOrtho: isOrthoProjection.value
},
selector: null
}
]
})
}
const writeIsGhostToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
isGhost: isGhostActive.value
},
selector: null
}
]
})
}
const writeZoomOnFilterToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
zoomOnFilter: isZoomOnFilterActive.value
},
selector: null
}
]
})
}
const writeViewModeToFile = (viewMode: ViewMode) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
@@ -201,25 +280,57 @@ export const useVisualStore = defineStore('visualStore', () => {
})
}
const writeHideBrandingToFile = (brandingHidden: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'workspace',
properties: {
brandingHidden: brandingHidden
},
selector: null
}
]
})
}
const writeNavbarVisibilityToFile = (navbarHidden: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'viewMode',
properties: {
navbarHidden: navbarHidden
},
selector: null
}
]
})
}
const writeCameraPositionToFile = (position: Vector3, target: Vector3) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
// postFileSaveSkipNeeded.value = true
// host.value.persistProperties({
// merge: [
// {
// objectName: 'cameraPosition',
// properties: {
// positionX: position.x,
// positionY: position.y,
// positionZ: position.z,
// targetX: target.x,
// targetY: target.y,
// targetZ: target.z
// },
// selector: null
// }
// ]
// })
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'cameraPosition',
properties: {
positionX: position.x,
positionY: position.y,
positionZ: position.z,
targetX: target.x,
targetY: target.y,
targetZ: target.z
},
selector: null
}
]
})
}
const setFieldInputState = (newFieldInputState: FieldInputState) =>
@@ -229,10 +340,40 @@ export const useVisualStore = defineStore('visualStore', () => {
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
const setViewerReadyToLoad = () => (isViewerReadyToLoad.value = true)
const setViewerReadyToLoad = (newValue: boolean) => (isViewerReadyToLoad.value = newValue)
const setViewerReloadNeeded = () => (viewerReloadNeeded.value = true)
const toggleBranding = () => {
isBrandingHidden.value = !isBrandingHidden.value
writeHideBrandingToFile(isBrandingHidden.value)
}
const setBrandingHidden = (val: boolean) => {
isBrandingHidden.value = val
}
const setNavbarHidden = (val: boolean) => {
isNavbarHidden.value = val
}
const toggleNavbar = () => {
isNavbarHidden.value = !isNavbarHidden.value
writeNavbarVisibilityToFile(isNavbarHidden.value)
}
const setIsOrthoProjection = (val: boolean) => {
isOrthoProjection.value = val
}
const setIsGhost = (val: boolean) => {
isGhostActive.value = val
}
const setIsZoomOnFilterActive = (val: boolean) => {
isZoomOnFilterActive.value = val
}
const setPostFileSaveSkipNeeded = (newValue: boolean) => (postFileSaveSkipNeeded.value = newValue)
const setPostClickSkipNeeded = (newValue: boolean) => (postClickSkipNeeded.value = newValue)
@@ -244,13 +385,58 @@ export const useVisualStore = defineStore('visualStore', () => {
(formattingSettings.value = newFormattingSettings)
const resetFilters = () => {
viewerEmit.value('resetFilter', dataInput.value.objectIds)
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
if (latestColorBy.value !== null) {
viewerEmit.value('colorObjectsByGroup', latestColorBy.value)
}
isFilterActive.value = false
}
const downloadLatestVersion = () => {
host.value.launchUrl(latestAvailableVersion.value?.Url as string)
}
const setCommonError = (error: string) => {
commonError.value = error
}
const handleObjectsLoadedComplete = () => {
console.log('🔄 Objects loaded - handling state restoration')
// If we have current data input with selections, restore them
if (dataInput.value) {
console.log('🔄 Restoring selection state after object load')
// Restore selection filters if they exist
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
}
// Restore color grouping
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
// Trigger host data refresh to synchronize with Power BI
host.value.refreshHostData()
}
return {
host,
receiveInfo,
@@ -274,7 +460,22 @@ export const useVisualStore = defineStore('visualStore', () => {
isFilterActive,
latestColorBy,
formattingSettings,
isBrandingHidden,
isOrthoProjection,
isGhostActive,
isNavbarHidden,
isZoomOnFilterActive,
latestAvailableVersion,
isConnectorUpToDate,
commonError,
setCommonError,
setLatestAvailableVersion,
setIsOrthoProjection,
setIsGhost,
setIsZoomOnFilterActive,
setFormattingSettings,
setBrandingHidden,
setNavbarHidden,
setPostClickSkipNeeded,
setPostFileSaveSkipNeeded,
setCameraPositionInFile,
@@ -287,8 +488,15 @@ export const useVisualStore = defineStore('visualStore', () => {
setObjectsFromStore,
writeObjectsToFile,
writeCameraViewToFile,
writeIsGhostToFile,
writeZoomOnFilterToFile,
writeIsOrthoToFile,
writeViewModeToFile,
writeCameraPositionToFile,
writeHideBrandingToFile,
writeNavbarVisibilityToFile,
toggleBranding,
toggleNavbar,
setViewerEmitter,
setDataInput,
setFieldInputState,
@@ -297,6 +505,8 @@ export const useVisualStore = defineStore('visualStore', () => {
setLoadingProgress,
clearLoadingProgress,
setIsLoadingFromFile,
resetFilters
resetFilters,
downloadLatestVersion,
handleObjectsLoadedComplete
}
})
+85 -11
View File
@@ -10,6 +10,7 @@ import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { FieldInputState, useVisualStore } from '@src/store/visualStore'
import { delay } from 'lodash'
import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
export class AsyncPause {
private lastPauseTime = 0
@@ -159,6 +160,40 @@ export type ReceiveInfo = {
version?: string
}
export type PreGetObjects = {
modelExists: boolean
objectCount?: number
}
async function getPreGetObjects(commaSeparatedModelIds: string): Promise<PreGetObjects[]> {
const modelIds = (commaSeparatedModelIds as string).split(',')
const preGetObjects = []
for await (const id of modelIds) {
const res = await getPreGetObjectsForModel(id)
preGetObjects.push(res)
}
return preGetObjects
}
async function getPreGetObjectsForModel(id: string): Promise<PreGetObjects> {
try {
const preGetObjectsRes = await fetch(`http://localhost:29364/pre-get-objects/${id}`)
if (!preGetObjectsRes.body) {
console.log('No response body for pre get objects')
return {
modelExists: false,
objectCount: null
} as PreGetObjects
}
return (await preGetObjectsRes.json()) as PreGetObjects
} catch (error) {
console.log(error)
}
}
async function getReceiveInfo(id) {
try {
const ids = (id as string).split(',')
@@ -175,19 +210,29 @@ async function getReceiveInfo(id) {
}
}
async function fetchStreamedData(commaSeparatedModelIds: string) {
async function fetchStreamedData(commaSeparatedModelIds: string, totalObjectCount: number) {
const modelIds = (commaSeparatedModelIds as string).split(',')
const modelObjects = []
let loadedObjectCount = 0
for await (const id of modelIds) {
const objects = await fetchStreamedDataForModel(id)
const objects = await fetchStreamedDataForModel(id, totalObjectCount, loadedObjectCount)
modelObjects.push(objects)
loadedObjectCount += objects.length
}
return modelObjects
}
async function fetchStreamedDataForModel(id) {
async function fetchStreamedDataForModel(
id: string,
totalObjectCount: number,
loadedObjectCount: number
) {
console.log(loadedObjectCount, totalObjectCount)
try {
const visualStore = useVisualStore()
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
if (!response.body) {
@@ -214,6 +259,11 @@ async function fetchStreamedDataForModel(id) {
try {
const obj = JSON.parse(jsonString)
objects.push(obj)
visualStore.setLoadingProgress(
'Loading objects from storage',
(objects.length + loadedObjectCount) / totalObjectCount
)
// console.log('Loading', (objects.length + loadedObjectCount) / totalObjectCount)
// console.log('Received object:', jsonObject)
} catch (e) {
@@ -246,9 +296,14 @@ async function fetchStreamedDataForModel(id) {
const endObjectCleanup = performance.now()
console.log(`Objects cleaned up in: ${(endObjectCleanup - startObjectCleanup) / 1000} s`)
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
const sizeInMB = sizeInBytes / (1024 * 1024)
console.log(`Size of objects: ${sizeInMB} MB`)
try {
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
const sizeInMB = sizeInBytes / (1024 * 1024)
console.log(`Size of objects: ${sizeInMB} MB`)
} catch (error) {
console.log("Can't calculate the size of the model")
console.log(error)
}
return objects
} catch (error) {
@@ -303,11 +358,16 @@ export async function processMatrixView(
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
const start = performance.now()
visualStore.setViewerReadyToLoad()
visualStore.setLoadingProgress('Loading', null)
// stream data
modelObjects = await fetchStreamedData(id)
const getPreGetObjectsRes: PreGetObjects[] = await getPreGetObjects(id)
if (getPreGetObjectsRes.some((preGetObjects) => preGetObjects.modelExists === false)) {
visualStore.setCommonError(
'Version Object ID is not found in storage. Please make sure you placed correct field or consider refreshing your data via data connector.'
)
visualStore.setViewerReadyToLoad(false)
return
}
const receiveInfo = await getReceiveInfo(id)
if (receiveInfo) {
@@ -321,13 +381,27 @@ export async function processMatrixView(
version: receiveInfo.version,
canHideBranding: receiveInfo.canHideBranding
})
console.log(`Receive info retrieved from desktop service`, receiveInfo)
}
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
const totalObjectCount = getPreGetObjectsRes.reduce((sum, obj) => {
return sum + (obj.objectCount ?? 0)
}, 0)
visualStore.setViewerReadyToLoad(true)
// stream data
modelObjects = await fetchStreamedData(id, totalObjectCount)
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
visualStore.setLoadingProgress('Loading objects into viewer', null)
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
}
if (visualStore.receiveInfo && visualStore.receiveInfo.version) {
const { checkUpdate } = useUpdateConnector()
await checkUpdate()
}
// If colors assigned, data arrives nested
if (hasColorFilter) {
// const start = performance.now()
+2 -1
View File
@@ -31,7 +31,8 @@ export class Tracker {
// eslint-disable-next-line camelcase
server_id: hashedServer,
email: receiveInfo.userEmail,
isAnonymous: receiveInfo.userEmail === ''
isAnonymous: receiveInfo.userEmail === '',
core_version: receiveInfo.version
}
}
@@ -1,19 +1,7 @@
<template>
<div
v-if="visualStore.loadingProgress"
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-20 text-center text-sm"
>
<!-- Progress Bar -->
<LoadingBar :loading="!!visualStore.loadingProgress"></LoadingBar>
</div>
<viewer-wrapper id="speckle-3d-view" class="h-full w-full cursor-default"></viewer-wrapper>
</template>
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { useVisualStore } from '../store/visualStore'
import LoadingBar from '@src/components/loading/LoadingBar.vue'
const visualStore = useVisualStore()
</script>
+73 -3
View File
@@ -4,6 +4,7 @@ import '../style/visual.css'
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
import { createApp } from 'vue'
import App from './App.vue'
import VueTippy from 'vue-tippy'
import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { SpeckleDataInput } from './types'
@@ -46,6 +47,11 @@ export class Visual implements IVisual {
console.log('🚀 Init Vue App')
createApp(App)
.use(pinia)
.use(VueTippy, {
defaultProps: {
theme: 'custom'
}
})
// .use(store, storeKey)
.provide(selectionHandlerKey, this.selectionHandler)
.provide(tooltipHandlerKey, this.tooltipHandler)
@@ -63,6 +69,10 @@ export class Visual implements IVisual {
public async update(options: VisualUpdateOptions) {
const visualStore = useVisualStore()
if (visualStore.commonError) {
visualStore.setCommonError(undefined)
visualStore.setViewerReadyToLoad(false)
}
if (visualStore.postFileSaveSkipNeeded) {
visualStore.setPostFileSaveSkipNeeded(false)
@@ -126,7 +136,32 @@ export class Visual implements IVisual {
)
}
if (options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean) {
console.log(
`Branding Hidden: ${
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
}`
)
visualStore.setBrandingHidden(
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
)
}
if (options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean) {
console.log(
`Navbar Hidden: ${
options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean
}`
)
visualStore.setNavbarHidden(
options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean
)
}
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
console.log(`Stored camera position is found`)
visualStore.setCameraPositionInFile([
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionX),
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionY),
@@ -136,6 +171,41 @@ export class Visual implements IVisual {
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetZ)
])
}
const camera = options.dataViews[0].metadata.objects.camera
if (camera && 'isOrtho' in camera) {
console.log(
`Projection is ortho?: ${
options.dataViews[0].metadata.objects.camera?.isOrtho as boolean
}`
)
visualStore.setIsOrthoProjection(
options.dataViews[0].metadata.objects.camera?.isOrtho as boolean
)
}
if (camera && 'isGhost' in camera) {
console.log(
`Is ghost?: ${options.dataViews[0].metadata.objects.camera?.isGhost as boolean}`
)
visualStore.setIsGhost(
options.dataViews[0].metadata.objects.camera?.isGhost as boolean
)
}
if (camera && 'zoomOnFilter' in camera) {
console.log(
`Zoom on filter?: ${options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean}`
)
visualStore.setIsZoomOnFilterActive(
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
)
}
// get receive info from file for mixpanel
try {
const receiveInfoFromFile = JSON.parse(
@@ -146,8 +216,8 @@ export class Visual implements IVisual {
console.warn(error)
console.log('missing mixpanel info')
}
const savedVersionObjectId = objectsFromFile.map((o) => o[0].id).join(',')
const savedVersionObjectId = objectsFromFile.map((o) => o[0].id).join(',')
if (visualStore.lastLoadedRootObjectId !== savedVersionObjectId) {
this.tryReadFromFile(objectsFromFile, visualStore)
}
@@ -198,7 +268,7 @@ export class Visual implements IVisual {
const visualStore = useVisualStore()
this.tooltipHandler.setup(input.objectTooltipData)
visualStore.setViewerReadyToLoad()
visualStore.setViewerReadyToLoad(true)
if (visualStore.isViewerInitialized && !visualStore.viewerReloadNeeded) {
visualStore.setDataInput(input)
@@ -212,7 +282,7 @@ export class Visual implements IVisual {
}
private tryReadFromFile(objectsFromFile: object[][], visualStore) {
visualStore.setViewerReadyToLoad()
visualStore.setViewerReadyToLoad(true)
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
setTimeout(() => {
visualStore.loadObjectsFromFile(objectsFromFile)