Compare commits

...

33 Commits

Author SHA1 Message Date
Dogukan Karatas 34115d9a5d feat (visual): align visual with FE2 (#222)
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
* view mode menu updated

* clean up
2026-01-19 23:19:51 +03:00
Mucahit Bilal GOKER 74ac3e3990 add object and application ids (#221) 2026-01-15 20:40:46 +03:00
Mucahit Bilal GOKER f9b5e250d8 feat: Issues helper function (#194)
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
* helper func

* register

* query: remove code duplicate

* discussions gone, issues in.

* add issue urls

* add optional replies input
2026-01-06 16:02:53 +03:00
Dogukan Karatas 0befca0200 Merge pull request #220 from specklesystems/dogukan/delimeter-fix
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 (data): federate helper function delimeter fix
2025-12-12 15:59:29 +01:00
Dogukan Karatas 1a74336e27 fixed delimeter 2025-12-12 15:57:05 +01:00
Dogukan Karatas 9f9b31d9ba Merge pull request #219 from specklesystems/dogukan/optional-version-object-id
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 (data): keep version object id for backwards compatability
2025-11-14 14:15:58 +01:00
Dogukan Karatas df3ad118e1 backwards compatible 2025-11-14 13:29:25 +01:00
Dogukan Karatas ec634be352 feat: auth flow without desktop service (#218)
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
* first pass

* column renaming

* mark received in visual

* some security measures

* renamed
2025-11-11 13:38:43 +03:00
Dogukan Karatas 0a4ae9340a Merge pull request #217 from specklesystems/dogukan/bump-ol2-version
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: objectloader2 version update
2025-11-05 16:37:17 +01:00
Dogukan Karatas 92bcf4b5c0 bump ol2 version 2025-11-05 16:06:13 +01:00
Dogukan Karatas 2a22bbf0af Merge pull request #216 from specklesystems/dogukan/arrange-buttons
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): button rearrangements
2025-11-03 13:45:28 +01:00
Dogukan Karatas 7b5e5397b6 minor changes 2025-11-03 13:35:15 +01:00
Dogukan Karatas 24eeb44ff7 Merge pull request #215 from specklesystems/dogukan/direct-server-download
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 (data): direct server download
2025-10-30 20:41:55 +01:00
Dogukan Karatas b1f16c4005 modifies the download procedure 2025-10-30 17:14:29 +01:00
Jedd Morgan 2307d87735 fix version again (#212) 2025-10-20 17:12:09 +01:00
Jedd Morgan b80624396d Update deploy.yml (#211) 2025-10-20 16:55:42 +01:00
Oğuzhan Koral 098ef3d112 Bump viewer for proxy fix (#210)
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-10-20 10:44:54 +03:00
Oğuzhan Koral 94fdc7a2c3 bump viewer (#209)
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-10-16 17:33:36 +03:00
Dogukan Karatas 525857bd26 adds version id suffix (#207)
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
Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2025-10-09 22:24:40 +03:00
Dogukan Karatas 959bcaa671 added a env check (#208) 2025-10-09 22:22:03 +03:00
Dogukan Karatas 04b3aef829 Merge pull request #206 from specklesystems/oguzhan/objectloader2
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): objectloader2 integration
2025-10-01 14:10:40 +02:00
Dogukan Karatas 318dc6dbbe cleanup added 2025-10-01 13:46:54 +02:00
Dogukan Karatas 20577a1fdb version bump 2025-10-01 12:14:11 +02:00
Dogukan Karatas e74bad829e downloads missing objects 2025-10-01 11:59:44 +02:00
oguzhankoral dda04e49c2 get root object first 2025-09-30 14:03:29 +03:00
Dogukan Karatas 97983fb8aa Revert "loader integration"
This reverts commit 53e4cda456.
2025-09-25 12:02:26 +02:00
Mucahit Bilal GOKER 1cac02ae61 Merge pull request #205 from specklesystems/bilal/cnx-2596-auto-expand-properties
feat: Add Property Expansion Option
2025-09-25 12:29:58 +03:00
bimgeek 0a5001987e remove true from description 2025-09-25 11:01:26 +03:00
bimgeek 5ffb3ea1dd set default value to false 2025-09-25 10:49:40 +03:00
bimgeek 3461c48b11 try check 2025-09-25 10:26:24 +03:00
bimgeek 220946a611 property expansion option 2025-09-25 10:19:35 +03:00
Dogukan Karatas 53e4cda456 loader integration 2025-09-23 15:00:50 +02:00
oguzhankoral 4ca0ae0978 replace objectloader1 with 2 2025-09-18 15:46:46 +03:00
26 changed files with 5169 additions and 2459 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
TAG="v3.0.99"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
@@ -81,7 +81,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
TAG="v3.0.99"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
+13
View File
@@ -189,6 +189,12 @@ shared Speckle.Models.MaterialQuantities = Value.ReplaceType(
type function (inputTable as table, optional addPrefix as logical) as table
);
[DataSource.Kind = "Speckle"]
shared Speckle.Project.Issues = Value.ReplaceType(
Speckle.LoadFunction("Project.Issues.pqm"),
type function (url as Uri.Type, optional getReplies as logical) as table
);
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
@@ -199,6 +205,13 @@ shared Speckle.GetByUrl = Value.ReplaceType(
Documentation.FieldDescription = "The URL of a model in a Speckle server project. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/7902de1f57/models/7f890a65df"}
]
),
optional ExpandProperties as (
type logical meta [
Documentation.FieldCaption = "Expand Properties (may slow query)",
Documentation.FieldDescription = "Expand the properties column into individual columns for easier analysis. When checked, each property from the 'properties' record column will have its own column. This can slow down the query if you have a lot of properties.",
Documentation.AllowedValues = {true, false}
]
)
) as table meta [
Documentation.Name = "Speckle - Get Data by URL",
+191 -37
View File
@@ -1,11 +1,19 @@
(url as text) as table =>
(url as text, optional ExpandProperties as logical) as table =>
let
// set default value for ExpandProperties
shouldExpandProperties = if ExpandProperties = null then false else ExpandProperties,
// import required functions
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
CheckPermissions = Extension.LoadFunction("CheckPermissions.pqm"),
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
EncodeUserInfo = Extension.LoadFunction("EncodeUserInfo.pqm"),
GetUser = Extension.LoadFunction("GetUser.pqm"),
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
@@ -26,24 +34,54 @@
// parse the URL to determine if it's a federated model
parsedUrl = Parser(url),
// check if user has permission to load the model
permissionCheck = CheckPermissions(url),
// assert that permission check returned a valid result
permissionAssert = if not Record.HasFields(permissionCheck, {"authorized", "code", "message"}) then
error "Invalid permission check result"
else
null,
// if not authorized, throw an error with the message from the server
authCheck = if not permissionCheck[authorized] then
error Text.Format(
"Permission denied: #{0} (Error code: #{1})",
"Permission denied: #{0} (Error code: #{1})",
{permissionCheck[message], permissionCheck[code]}
)
else
null,
// get user info, connector version, and workspace info for encoding
userInfo = GetUser(url),
powerfulToken = userInfo[Token],
userEmail = userInfo[UserEmail],
connectorVersion = GetVersion(),
workspaceInfo = GetWorkspace(url),
// exchange powerful token for weak token with limited scopes
tokenExchangeResult = ExchangeToken(
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
),
// throw error if token exchange failed - do NOT use powerful token as fallback
tokenToUse = if tokenExchangeResult[Success] then
tokenExchangeResult[Token]
else
error [
Reason = "TokenExchangeFailed",
Message.Format = "Failed to exchange token for limited scope token: #{0}",
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
Detail = [
ErrorMessage = tokenExchangeResult[ErrorMessage],
ProjectId = parsedUrl[projectId],
ServerUrl = parsedUrl[baseUrl]
]
],
// only proceed if user has permisson to load
results = if permissionCheck[authorized] then
@@ -53,45 +91,105 @@
modelsData = List.Transform(
parsedUrl[federatedModels],
each ProcessSingleModel(
parsedUrl[baseUrl],
parsedUrl[projectId],
[modelId],
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]),
// extract all encoded userInfo strings
allEncodedUserInfos = List.Transform(modelsData, each [EncodedUserInfo]),
// combine all root object IDs into a comma-separated string
combinedRootIds = Text.Combine(allRootIds, ","),
// combine all encoded userInfo strings with delimiter |||
// (delimiter chosen to avoid conflicts with base64 characters)
combinedEncodedUserInfos = Text.Combine(allEncodedUserInfos, "|||"),
// 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}
)
// replace both columns with combined values
transformedData = Table.TransformColumns(
combinedData,
{
{"Version Object ID", each combinedRootIds},
{"Model Info", each combinedEncodedUserInfos}
}
),
// expand properties column if requested and if it exists
finalData = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
try
Speckle.Utils.ExpandRecord(transformedData, "properties")
otherwise
transformedData // fallback to original data if expansion fails
else
transformedData
in
finalData
else
// use existing functionality for single models
let
// get model name
// get model info
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
rootObjectId = modelInfo[rootObjectId],
sourceApplication = modelInfo[sourceApplication],
versionId = modelInfo[versionId],
// get structured data
structuredData = GetStructuredData(url),
// rename column based on send status
newColumnName = "Version Object ID",
result = Table.RenameColumns(structuredData, {{"Version Object ID", newColumnName}})
// build userInfoData record for this model
userInfoData = [
rootObjectId = rootObjectId,
server = parsedUrl[baseUrl],
email = userEmail,
projectId = parsedUrl[projectId],
token = tokenToUse,
workspaceId = workspaceInfo[workspaceId],
workspaceName = workspaceInfo[workspaceName],
workspaceLogo = workspaceInfo[workspaceLogo],
version = connectorVersion,
sourceApplication = sourceApplication,
canHideBranding = workspaceInfo[canHideBranding],
versionId = versionId,
url = url
],
// try to send to desktop service for backward compatibility (non-blocking)
// must be called BEFORE encoding to ensure it executes
desktopServiceSent = TrySendToDesktopService(userInfoData),
// encode userInfoData as base64 JSON string
encodedUserInfo = EncodeUserInfo(userInfoData),
// replace both columns with appropriate values
transformedData = Table.TransformColumns(
structuredData,
{
{"Version Object ID", each rootObjectId},
{"Model Info", each if desktopServiceSent or not desktopServiceSent then encodedUserInfo else encodedUserInfo}
}
),
// expand properties column if requested and if it exists
result = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
try
Speckle.Utils.ExpandRecord(transformedData, "properties")
otherwise
transformedData // fallback to original data if expansion fails
else
transformedData
in
result
else
@@ -100,38 +198,94 @@
{permissionCheck[message], permissionCheck[code]}
),
// helper function to try sending user info to desktop service for backward compatibility
// returns true if successful, false otherwise (non-blocking)
TrySendToDesktopService = (userInfoData as record) =>
try
let
userInfoJson = Json.FromValue(userInfoData),
response = Web.Contents(
"http://127.0.0.1:29364/store-user-info",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = userInfoJson,
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 2)
]
),
statusCode = Value.Metadata(response)[Response.Status]
in
statusCode >= 200 and statusCode < 300
otherwise
false,
// function to process a single model and get its data
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
let
// construct a standard URL for the model
singleModelUrl = Text.Combine({
baseUrl,
"/projects/",
projectId,
"/models/",
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],
sourceApplication = modelInfo[sourceApplication],
// get structured data
structuredData = GetStructuredData(singleModelUrl),
// add the model name as context
// build userInfoData record for this model
userInfoData = [
rootObjectId = rootObjectId,
server = baseUrl,
email = userEmail,
projectId = projectId,
token = tokenToUse,
workspaceId = workspaceInfo[workspaceId],
workspaceName = workspaceInfo[workspaceName],
workspaceLogo = workspaceInfo[workspaceLogo],
version = connectorVersion,
sourceApplication = sourceApplication,
canHideBranding = workspaceInfo[canHideBranding],
versionId = if versionId <> null then versionId else modelInfo[versionId],
url = singleModelUrl
],
// try to send to desktop service for backward compatibility (non-blocking)
// must be called BEFORE encoding to ensure it executes
desktopServiceSent = TrySendToDesktopService(userInfoData),
// encode userInfoData as base64 JSON string
encodedUserInfo = EncodeUserInfo(userInfoData),
// add the model name as context - with version id if exists
// reference desktopServiceSent to force evaluation
result = Table.AddColumn(
structuredData,
"Source Model",
each modelName,
structuredData,
"Source Model",
each if versionId <> null then
Text.Combine({modelName, "-", versionId})
else if desktopServiceSent or not desktopServiceSent then
modelName
else
modelName,
type text
)
in
[
Data = result,
RootObjectId = rootObjectId
RootObjectId = rootObjectId,
EncodedUserInfo = encodedUserInfo
]
in
results
@@ -0,0 +1,18 @@
// Function to encode userInfoData as base64-encoded JSON string
(userInfoData as record) as text =>
let
JsonText = Text.FromBinary(
Json.FromValue(userInfoData),
TextEncoding.Utf8
),
// Convert JSON text to binary
JsonBinary = Text.ToBinary(JsonText, TextEncoding.Utf8),
// Encode binary as base64
Base64Encoded = Binary.ToText(JsonBinary, BinaryEncoding.Base64),
// Return base64-encoded string
Result = Base64Encoded
in
Result
@@ -0,0 +1,146 @@
// Function to exchange powerful token for weak limited token
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text) as record =>
let
// Import the parser function for URL handling
Parser = Extension.LoadFunction("Parser.pqm"),
// Helper function to load .pqm modules dynamically
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// Validate inputs
ValidationError = if Text.Length(powerfulToken) = 0 then
"PowerfulToken is required"
else if List.Count(scopes) = 0 then
"Scopes are required"
else if Text.Length(projectId) = 0 then
"ProjectId is required"
else if Text.Length(serverUrl) = 0 then
"ServerUrl is required"
else
null,
// Token lifetime: 10 years (315,360,000,000 milliseconds)
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
// Generate token name with timestamp
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
// Build scopes array string for GraphQL (e.g., ["profile:read", "streams:read"])
ScopesArray = Text.Combine(
List.Transform(scopes, each """" & _ & """"),
", "
),
// Build GraphQL mutation
GraphQLMutation = "
mutation {
apiTokenCreate(token: {
name: """ & TokenName & """,
scopes: [" & ScopesArray & "],
lifespan: " & Number.ToText(TokenLifespanMs) & ",
limitResources: [{
type: project,
id: """ & projectId & """
}]
})
}",
// Execute token exchange if validation passes
Result = if ValidationError <> null then
[
Success = false,
Token = null,
ErrorMessage = ValidationError
]
else
try
let
// Ensure serverUrl ends with /
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
serverUrl
else
serverUrl & "/",
// Make GraphQL request
Response = Web.Contents(
NormalizedServerUrl & "graphql",
[
Headers = [
#"Method" = "POST",
#"Content-Type" = "application/json",
#"Authorization" = "Bearer " & powerfulToken
],
Content = Json.FromValue([
query = GraphQLMutation
]),
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 10)
]
),
StatusCode = Value.Metadata(Response)[Response.Status],
// Parse response if successful
ParsedResult = if StatusCode >= 200 and StatusCode < 300 then
let
JsonResponse = Json.Document(Response),
// Check for GraphQL errors
HasErrors = Record.HasFields(JsonResponse, {"errors"}),
// Extract token from response
WeakToken = if not HasErrors then
JsonResponse[data][apiTokenCreate]
else
null,
ErrorMsg = if HasErrors then
try
JsonResponse[errors]{0}[message]
otherwise
"GraphQL mutation failed with unknown error"
else
null
in
if WeakToken <> null then
[
Success = true,
Token = WeakToken,
ErrorMessage = null
]
else
[
Success = false,
Token = null,
ErrorMessage = ErrorMsg
]
else
[
Success = false,
Token = null,
ErrorMessage = "GraphQL request failed with status " & Number.ToText(StatusCode)
]
in
ParsedResult
otherwise
[
Success = false,
Token = null,
ErrorMessage = "Token exchange request failed with exception"
]
in
Result
@@ -56,6 +56,7 @@
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Version Object ID" = rootId,
#"Model Info" = rootId,
data = cleanedRecord // Data
]
)
@@ -1,30 +1,42 @@
// function for federating multiple tables by combining them and creating a concatenated Version Object ID
// function for federating multiple tables by combining them and creating concatenated Version Object ID and Model Info fields
(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
if ViewerOnly then
Table.SelectColumns(_, {"Version Object ID", "Model Info", "Object IDs"}, MissingField.Ignore)
else
_
),
CombinedTable = Table.Combine(ProcessedTables),
DistinctVersionObjectIDs = List.Distinct(CombinedTable[Version Object ID]),
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
DistinctModelInfo = List.Distinct(CombinedTable[Model Info]),
ConcatenatedModelInfo = Text.Combine(DistinctModelInfo, "|||"),
// Replace all Version Object ID values with the concatenated string
FederatedTable = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
TableWithVersionObjectID = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
{"Version Object ID"}
),
// Replace all Model Info values with the concatenated string
FederatedTable = Table.ReplaceValue(
TableWithVersionObjectID,
each [Model Info],
ConcatenatedModelInfo,
Replacer.ReplaceText,
{"Model Info"}
)
in
FederatedTable
@@ -0,0 +1,173 @@
// Function for getting issues from Speckle projects, models, or versions
(url as text, optional getReplies as logical) as table =>
let
// Import required functions
Parser = Extension.LoadFunction("Parser.pqm"),
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
// Set default value for getReplies parameter
getRepliesValue = if getReplies = null then false else getReplies,
// Extension.LoadFunction logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// Parse the URL to get necessary components with fallback for project-only URLs
parsedUrl = try Parser(url) otherwise
// Custom parsing for project-only URLs
let
urlParts = Uri.Parts(url),
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
then pathSegments{1} else null
in
if projectId = null then
error [
Reason = "Invalid URL",
Message = "The URL must be a valid Speckle project URL in the format 'https://server/projects/PROJECT_ID' or include models/versions"
]
else
[
baseUrl = baseUrl,
projectId = projectId,
modelId = null,
versionId = null
],
server = parsedUrl[baseUrl],
projectId = parsedUrl[projectId],
modelId = parsedUrl[modelId],
versionId = parsedUrl[versionId],
// Define the GraphQL query (single query for all scopes)
issuesQuery = "query Project($projectId: String!, $input: ProjectIssuesInput" &
(if getRepliesValue then ", $repliesInput2: IssueRepliesInput" else "") & ") {
project(id: $projectId) {
issues(input: $input) {
items {
identifier
title
rawDescription
status
priority
assignee {
user {
name
}
}
dueDate
labels {
name
}
createdAt
updatedAt
resourceIdString
viewerState
id" &
(if getRepliesValue then "
replies(input: $repliesInput2) {
items {
issueId
id
rawDescription
createdAt
author {
user {
name
}
}
}
}" else "") & "
}
}
}
}",
// Build input variable dynamically based on URL scope
inputVariable =
if versionId <> null then
// Version URL: resourceIdString = "MODEL_ID@VERSION_ID"
[
limit = 10000,
resourceIdString = modelId & "@" & versionId
]
else if modelId <> null then
// Model URL: resourceIdString = MODEL_ID
[
limit = 10000,
resourceIdString = modelId
]
else
// Project URL: no resourceIdString
[
limit = 10000
],
// Build query variables
queryVariables = if getRepliesValue then
[
projectId = projectId,
input = inputVariable,
repliesInput2 = [limit = 10000]
]
else
[
projectId = projectId,
input = inputVariable
],
// Make the API request using ApiFetch
result = ApiFetch(server, issuesQuery, queryVariables),
// Extract issues from the response
issues = result[project][issues][items],
// Transform to table structure with specified columns
issuesTable = Table.FromRecords(
List.Transform(issues, (issue) =>
let
// Extract selectedObjectApplicationIds from viewerState (already a record object)
viewerState = try issue[viewerState] otherwise null,
selectedObjectIds = try viewerState[ui][filters][selectedObjectApplicationIds] otherwise null,
objectIds = try Record.FieldNames(selectedObjectIds) otherwise null,
applicationIds = try Record.FieldValues(selectedObjectIds) otherwise null,
baseRecord = [
ID = issue[identifier],
Title = issue[title],
Description = try issue[rawDescription] otherwise null,
Status = try issue[status] otherwise null,
Priority = try issue[priority] otherwise null,
Assignee = try issue[assignee][user][name] otherwise null,
#"Due Date" = try DateTime.From(issue[dueDate]) otherwise null,
Labels = try List.Transform(issue[labels], each _[name]) otherwise {},
#"Created at" = try DateTime.From(issue[createdAt]) otherwise null,
#"Updated at" = try DateTime.From(issue[updatedAt]) otherwise null,
URL = server & "/projects/" & projectId & "/models/" & issue[resourceIdString] & "#threadId=" & issue[id],
#"Object IDs" = objectIds,
#"Application IDs" = applicationIds
],
recordWithReplies = if getRepliesValue then
baseRecord & [Replies = try issue[replies][items] otherwise null]
else
baseRecord
in
recordWithReplies
)
)
in
issuesTable
@@ -1,13 +1,11 @@
(url as text) as list =>
let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
GetUser = Extension.LoadFunction("GetUser.pqm"),
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
// the logic for importing functions from other files
// helper function to load .pqm modules dynamically
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
@@ -24,130 +22,68 @@
Detail = [File = fileName, Error = e]
],
// Get required information
modelInfo = GetModel(url),
parsedUrl = Parser(url),
userInfo = GetUser(url),
apiKey = userInfo[Token],
userEmail = userInfo[UserEmail],
connectorVersion = GetVersion(),
workspaceInfo = GetWorkspace(url),
powerfulToken = userInfo[Token],
// Function to check if Desktop Service is available
IsDesktopServiceAvailable = () =>
try
let
PingResponse = Web.Contents(
"http://127.0.0.1:29364/ping",
[
Headers = [#"Method" = "GET"],
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 2) // 2 second timeout for ping
]
),
StatusCode = Value.Metadata(PingResponse)[Response.Status]
in
StatusCode = 200
otherwise
false,
// exchange powerful token for weak token using GraphQL
// this replaces the desktop service token exchange
tokenExchangeResult = ExchangeToken(
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
),
// Function to use Desktop Service approach (only called if available)
UseDesktopService = () =>
let
// exchange powerful token for weak token via ds
tokenExchangeData = Json.FromValue([
PowerfulToken = apiKey,
Scopes = {"profile:read", "streams:read", "users:read"},
// throw error if token exchange failed - do NOT use powerful token as fallback
tokenToUse = if tokenExchangeResult[Success] then
tokenExchangeResult[Token]
else
error [
Reason = "TokenExchangeFailed",
Message.Format = "Failed to exchange token for limited scope token: #{0}",
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
Detail = [
ErrorMessage = tokenExchangeResult[ErrorMessage],
ProjectId = parsedUrl[projectId],
ServerUrl = parsedUrl[baseUrl]
]),
tokenExchangeResponse = Web.Contents(
"http://127.0.0.1:29364/auth/exchange-token",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = tokenExchangeData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
tokenExchangeJson = Json.Document(tokenExchangeResponse),
weakToken = tokenExchangeJson[token],
]
],
// prepare request data with weak token
requestData = Json.FromValue([
Url = url,
Server = parsedUrl[baseUrl],
Email = userEmail,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId],
SourceApplication = modelInfo[sourceApplication],
Token = weakToken,
Version = connectorVersion,
VersionId = parsedUrl[versionId],
WorkspaceId = workspaceInfo[workspaceId],
WorkspaceName = workspaceInfo[workspaceName],
WorkspaceLogo = workspaceInfo[workspaceLogo],
CanHideBranding = workspaceInfo[canHideBranding]
]),
// Send request to local server
Response = Web.Contents(
"http://127.0.0.1:29364/download",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = requestData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
// Parse response
JsonResponse = Json.Document(Response)
in
JsonResponse,
// Function to fallback to direct JSON download from Speckle server
FallbackToDirectDownload = () =>
// downloads data directly from server
DirectDownload = (token as text) =>
let
// Construct the direct object URL: {baseUrl}/objects/{projectId}/{rootObjectId}
objectUrl = Text.Combine({
parsedUrl[baseUrl],
"/objects/",
parsedUrl[projectId],
"/",
parsedUrl[baseUrl],
"/objects/",
parsedUrl[projectId],
"/",
modelInfo[rootObjectId]
}),
// Download JSON directly from Speckle server
Response = Web.Contents(
objectUrl,
[
Headers = [
#"Authorization" = "Bearer " & apiKey,
#"Authorization" = "Bearer " & token,
#"Accept" = "application/json"
],
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
]
),
// Check response status
StatusCode = Value.Metadata(Response)[Response.Status],
// Parse JSON response if successful
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
Json.Document(Response)
else
error [
Reason = "DirectDownloadFailed",
Message = "Failed to download model data directly from Speckle server",
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
Message.Parameters = {Text.From(StatusCode)},
Detail = [
StatusCode = StatusCode,
StatusCode = StatusCode,
ObjectUrl = objectUrl,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId]
@@ -156,13 +92,8 @@
in
JsonResponse,
// Check Desktop Service availability and choose approach
DesktopServiceAvailable = IsDesktopServiceAvailable(),
FinalResult = if DesktopServiceAvailable then
UseDesktopService()
else
FallbackToDirectDownload()
// download data using the token (weak if exchange succeeded, powerful otherwise)
FinalResult = DirectDownload(tokenToUse)
in
FinalResult
FinalResult
+11 -2
View File
@@ -1,7 +1,7 @@
{
"dataRoles": [
{
"displayName": "Version Object ID",
"displayName": "Model Info",
"kind": "Measure",
"name": "rootObjectId"
},
@@ -92,6 +92,15 @@
},
"navbarHidden": {
"type": { "bool": true }
},
"edgesEnabled": {
"type": { "bool": true }
},
"edgesWeight": {
"type": { "numeric": true }
},
"edgesColor": {
"type": { "numeric": true }
}
}
},
@@ -218,7 +227,7 @@
{
"essential": true,
"name": "WebAccess",
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
"parameters": ["https://analytics.speckle.systems", "*"]
},
{
"essential": false,
+3571 -2020
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -17,11 +17,10 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader": "^2.25.9",
"@speckle/objectloader2": "^2.25.9",
"@speckle/objectloader2": "2.26.7",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
"@speckle/viewer": "2.26.5",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
@@ -33,13 +33,22 @@
</ViewerControlsButtonToggle>
</ViewerControlsButtonGroup>
<ViewerControlsButtonGroup>
<!-- View Modes -->
<ViewerViewModesMenu
:open="viewModesOpen"
@force-close-others="activeControl = 'none'"
@update:open="(value) => toggleActiveControl(value ? 'viewModes' : 'none')"
@view-mode-clicked="(value) => $emit('view-mode-clicked', value)"
/>
<!-- View Modes Toggle -->
<div class="relative">
<ViewerControlsButtonToggle
flat
tooltip="View modes"
:active="viewModesOpen"
@click="toggleActiveControl('viewModes')"
>
<ViewModesIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- View Modes Panel (shown when glasses icon is clicked) -->
<ViewerViewModesMenu
v-if="viewModesOpen"
@view-mode-clicked="(viewMode, options) => $emit('view-mode-clicked', viewMode, options)"
/>
</div>
<!-- Views -->
<ViewerViewsMenu
:open="viewsOpen"
@@ -65,7 +74,7 @@
<script setup lang="ts">
import { ArrowsPointingOutIcon } from '@heroicons/vue/24/solid'
import { SpeckleView } from '@speckle/viewer'
import { SpeckleView, ViewMode } from '@speckle/viewer'
import { computed, ref } from 'vue'
import { useVisualStore } from '@src/store/visualStore'
import ViewerControlsButtonGroup from './viewer/controls/ViewerControlsButtonGroup.vue'
@@ -76,19 +85,21 @@ import ViewerViewsMenu from './viewer/views/ViewerViewsMenu.vue'
import Perspective from '../components/global/icon/Perspective.vue'
import PerspectiveMore from '../components/global/icon/PerspectiveMore.vue'
import ViewModesIcon from '../components/global/icon/ViewModes.vue'
import Ghost from '../components/global/icon/Ghost.vue'
import ZoomToFit from '../components/global/icon/ZoomToFit.vue'
import type { ViewModeOptions } from '@src/plugins/viewer'
const visualStore = useVisualStore()
const emits = defineEmits([
'update:sectionBox',
'view-clicked',
'toggle-projection',
'clear-palette',
'view-mode-clicked'
])
const emits = defineEmits<{
(e: 'update:sectionBox', value: boolean): void
(e: 'view-clicked', view: SpeckleView): void
(e: 'toggle-projection'): void
(e: 'clear-palette'): void
(e: 'view-mode-clicked', viewMode: ViewMode, options: ViewModeOptions): void
}>()
withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false
})
@@ -130,12 +141,7 @@ const toggleZoomOnFilter = () => {
visualStore.writeZoomOnFilterToFile()
}
const viewModesOpen = computed({
get: () => activeControl.value === 'viewModes',
set: (value) => {
activeControl.value = value ? 'viewModes' : 'none'
}
})
const viewModesOpen = computed(() => activeControl.value === 'viewModes')
const viewsOpen = computed({
get: () => activeControl.value === 'views',
@@ -29,7 +29,7 @@
<div class="flex items-center space-x-2">
<FormButton
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate"
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate && visualStore.isRunningInDesktop"
v-tippy="{
content: 'New connector version is available.<br>Click to download.',
allowHTML: true
@@ -64,7 +64,7 @@
field is needed for interactivity with other visuals.
</div>
<div v-if="visualStore.isNavbarHidden" class="fixed top-0 right-0 z-20">
<div v-if="visualStore.isNavbarHidden" class="fixed top-4 right-2 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@@ -81,7 +81,7 @@
:views="views"
class="fixed top-11 left-2 z-30"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
@view-mode-clicked="(viewMode, options) => viewerHandler.setViewMode(viewMode, options)"
/>
</transition>
@@ -165,7 +165,7 @@ onMounted(async () => {
// Set up event listener for object clicks from the FilteredSelectionExtension
viewerHandler.emitter.on('objectClicked', handleObjectClicked)
visualStore.setViewerEmitter(viewerHandler.emit)
})
@@ -0,0 +1,104 @@
<template>
<div class="w-full flex flex-col gap-2">
<div class="flex items-center justify-between">
<label
:for="name"
class="block text-body-2xs text-foreground-2"
>
{{ label || name }}
</label>
<span class="text-body-2xs text-foreground-2">{{ displayValue }}</span>
</div>
<input
:id="name"
:name="name"
type="range"
:min="min"
:max="max"
:step="step"
:value="currentValue"
:disabled="disabled"
class="w-full h-1.5 outline-none slider"
:class="{
'disabled:opacity-50 disabled:cursor-not-allowed': disabled
}"
:aria-label="label"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="currentValue"
@input="handleInput"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{
min: number
max: number
step: number
name: string
label: string
disabled?: boolean
modelValue?: number
}>()
const emit = defineEmits(['update:modelValue'])
const currentValue = ref(props.modelValue ?? props.min)
// Watch for external changes to modelValue
watch(() => props.modelValue, (newVal) => {
if (newVal !== undefined && newVal !== currentValue.value) {
currentValue.value = newVal
}
})
const displayValue = computed(() => {
// Round to avoid floating point issues
return Math.round(currentValue.value * 10) / 10
})
const clampValue = (value: number): number => {
return Math.max(props.min, Math.min(props.max, value))
}
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = Number(target.value)
const clampedValue = clampValue(value)
currentValue.value = clampedValue
emit('update:modelValue', clampedValue)
}
</script>
<style scoped>
.slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.slider::-webkit-slider-runnable-track {
@apply h-1.5 rounded-full bg-outline-3;
}
.slider::-moz-range-track {
@apply h-1.5 rounded-full bg-outline-3;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
@apply h-2.5 w-2.5 rounded-full cursor-pointer bg-foreground-2;
margin-top: -2px;
}
.slider::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
@apply h-2.5 w-2.5 rounded-full cursor-pointer border-0 bg-foreground-2;
}
</style>
@@ -0,0 +1,48 @@
<template>
<div class="flex items-center space-x-2">
<button
:id="name"
type="button"
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
class="relative inline-flex flex-shrink-0 h-[18px] w-[30px] rounded-full transition-colors ease-in-out duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
:class="modelValue ? 'bg-primary' : 'bg-foreground-3'"
@click="toggle"
>
<span
class="pointer-events-none inline-block h-3 w-3 rounded-full mt-[3px] ml-[3px] ring-0 transition ease-in-out duration-200 bg-foreground-on-primary"
:class="modelValue ? 'translate-x-[12px]' : 'translate-x-0'"
/>
</button>
<label v-if="showLabel" :for="name" class="block label-light">
<span>{{ label || name }}</span>
</label>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue?: boolean
showLabel?: boolean
name: string
label?: string
disabled?: boolean
}>(),
{
showLabel: true,
modelValue: false
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const toggle = () => {
if (!props.disabled) {
emit('update:modelValue', !props.modelValue)
}
}
</script>
@@ -1,85 +1,204 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" title="View modes">
<template #trigger-icon>
<ViewModes class="h-5 w-5" />
</template>
<template #title>View modes</template>
<div
class="p-1.5"
@mouseenter="cancelCloseTimer"
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
@focusin="cancelCloseTimer"
@focusout="isManuallyOpened ? undefined : startCloseTimer"
>
<div v-for="(label, mode) in viewModes" :key="mode">
<ViewerMenuItem
:label="label"
:active="mode.toString() === visualStore.defaultViewModeInFile"
@click="handleViewModeChange(Number(mode))"
<div class="absolute left-10 sm:left-[46px] -top-0 bg-foundation rounded-md border border-outline-2 shadow min-w-[180px] z-30">
<!-- Header -->
<div class="px-2 py-1.5 border-b border-outline-2">
<span class="text-body-2xs font-medium text-foreground">View modes</span>
</div>
<!-- View Mode List -->
<div class="py-0.5">
<button
v-for="item in viewModes"
:key="item.mode"
class="w-full px-2 py-1 flex items-center hover:bg-highlight-1 text-left"
@click="handleViewModeChange(item.mode)"
>
<div class="flex items-center gap-1.5">
<CheckIcon
v-if="isActiveMode(item.mode)"
class="w-3.5 h-3.5 text-foreground"
/>
<span v-else class="w-3.5 h-3.5" />
<span class="text-body-2xs" :class="isActiveMode(item.mode) ? 'text-foreground font-medium' : 'text-foreground-2'">
{{ item.label }}
</span>
</div>
</button>
</div>
<!-- Edges Section -->
<div class="border-t border-outline-2 px-2 py-1.5 space-y-2">
<!-- Edges Toggle -->
<div class="flex items-center justify-between">
<span class="text-body-2xs text-foreground">Edges</span>
<FormSwitch
v-model="edgesEnabledLocal"
:show-label="false"
name="toggle-edges"
:disabled="currentViewMode === ViewMode.PEN"
/>
</div>
<!-- Weight Slider (only show when edges enabled) -->
<div v-if="edgesEnabledLocal" class="py-1">
<FormRange
v-model="edgesWeightLocal"
name="edge-weight"
label="Weight"
:min="0.5"
:max="3"
:step="0.1"
/>
</div>
<!-- Color Selector (only show when edges enabled) -->
<div v-if="edgesEnabledLocal" class="flex items-center justify-between">
<span class="text-body-2xs text-foreground-2">Color</span>
<div class="flex items-center gap-1">
<button
v-for="(color, index) in edgesColorOptions"
:key="color === 'auto' ? 'auto' : color"
class="flex items-center justify-center size-4 rounded-full"
:class="edgesColorLocal === color && 'ring-2 ring-primary ring-offset-1'"
@click="handleEdgesColorChange(color)"
>
<span
class="size-3 rounded-full cursor-pointer"
:style="{
background:
index === 0
? 'linear-gradient(135deg, #1a1a1a 50%, #ffffff 50%)'
: `#${(color as number).toString(16).padStart(6, '0')}`
}"
/>
</button>
</div>
</div>
</div>
</ViewerMenu>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { ViewMode } from '@speckle/viewer'
import ViewerMenu from '../menu/ViewerMenu.vue'
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
import { onUnmounted, ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useVisualStore } from '@src/store/visualStore'
import ViewModes from '../../global/icon/ViewModes.vue'
import FormSwitch from '../../form/FormSwitch.vue'
import FormRange from '../../form/FormRange.vue'
import { CheckIcon } from '@heroicons/vue/24/solid'
import type { ViewModeOptions } from '@src/plugins/viewer'
const viewModes = {
[ViewMode.DEFAULT]: 'Default',
[ViewMode.DEFAULT_EDGES]: 'Edges',
[ViewMode.SHADED]: 'Shaded',
[ViewMode.PEN]: 'Pen',
[ViewMode.ARCTIC]: 'Arctic',
[ViewMode.COLORS]: 'Colors'
}
// Array to maintain proper display order (matching Speckle frontend)
const viewModes = [
{ mode: ViewMode.DEFAULT, label: 'Rendered' },
{ mode: ViewMode.SHADED, label: 'Shaded' },
{ mode: ViewMode.ARCTIC, label: 'Arctic' },
{ mode: ViewMode.SOLID, label: 'Solid' },
{ mode: ViewMode.PEN, label: 'Pen' }
]
const edgesColorOptions = [
'auto' as const,
0x3b82f6, // blue-500
0x8b5cf6, // violet-500
0x65a30d, // lime-600
0xf97316, // orange-500
0xf43f5e // rose-500
]
const visualStore = useVisualStore()
// Props
const props = defineProps<{
open: boolean
}>()
// Emits
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'force-close-others'): void
(e: 'view-mode-clicked', value: ViewMode): void
(e: 'view-mode-clicked', value: ViewMode, options: ViewModeOptions): void
}>()
// Computed v-model
const open = computed({
get: () => props.open,
set: (val) => emit('update:open', val)
// Initialization flag
const isInitialized = ref(false)
// Local state synced with store (with safe defaults)
const edgesEnabledLocal = ref(visualStore.edgesEnabled ?? true)
const edgesWeightLocal = ref(visualStore.edgesWeight ?? 1)
const edgesColorLocal = ref<number | 'auto'>(visualStore.edgesColor ?? 'auto')
// Mark as initialized after next tick to prevent watchers firing on mount
onMounted(() => {
nextTick(() => {
isInitialized.value = true
})
})
// State
const isManuallyOpened = ref(false)
// Current view mode from store
const currentViewMode = computed(() => {
return visualStore.defaultViewModeInFile
? Number(visualStore.defaultViewModeInFile) as ViewMode
: ViewMode.DEFAULT
})
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
() => {
open.value = false
},
3000,
{ immediate: false }
)
const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value
const handleViewModeChange = (mode: ViewMode) => {
open.value = false
visualStore.setDefaultViewModeInFile(mode.toString())
visualStore.writeViewModeToFile(mode)
emit('view-mode-clicked', mode)
// Compute the actual edge color to use (auto resolves to dark)
const finalEdgesColor = computed(() => {
if (edgesColorLocal.value === 'auto') {
return 0x1a1a1a // dark edges by default
}
return edgesColorLocal.value
})
// Build view mode options
const buildViewModeOptions = (mode: ViewMode): ViewModeOptions => {
// PEN mode always has edges enabled and opacity 1
const isPenMode = mode === ViewMode.PEN
return {
edges: isPenMode ? true : edgesEnabledLocal.value,
outlineThickness: edgesWeightLocal.value,
outlineOpacity: isPenMode ? 1 : 0.75,
outlineColor: finalEdgesColor.value
}
}
onUnmounted(() => {
cancelCloseTimer()
const handleViewModeChange = (mode: ViewMode) => {
const options = buildViewModeOptions(mode)
visualStore.setDefaultViewModeInFile(mode.toString())
visualStore.writeViewModeToFile(mode)
emit('view-mode-clicked', mode, options)
}
const handleEdgesColorChange = (color: number | 'auto') => {
edgesColorLocal.value = color
}
// Apply edges changes to viewer when settings change
const applyEdgesSettings = () => {
// Don't apply during initialization
if (!isInitialized.value) return
// Update store
visualStore.setEdgesEnabled(edgesEnabledLocal.value)
visualStore.setEdgesWeight(edgesWeightLocal.value)
visualStore.setEdgesColor(edgesColorLocal.value)
visualStore.writeEdgesSettingsToFile()
// Re-apply current view mode with new options
const options = buildViewModeOptions(currentViewMode.value)
emit('view-mode-clicked', currentViewMode.value, options)
}
// Watch for edges settings changes and apply them
watch([edgesEnabledLocal, edgesWeightLocal, edgesColorLocal], () => {
applyEdgesSettings()
})
// Sync local state with store when store changes (e.g., from file load)
watch(() => visualStore.edgesEnabled, (val) => {
edgesEnabledLocal.value = val
})
watch(() => visualStore.edgesWeight, (val) => {
edgesWeightLocal.value = val
})
watch(() => visualStore.edgesColor, (val) => {
edgesColorLocal.value = val
})
</script>
@@ -41,8 +41,12 @@ export function useUpdateConnector() {
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 }
// Filter out prerelease versions
const stableVersions = sortedVersions.filter((v) => !v.Prerelease)
const latestVersion = stableVersions[0]
const sanitizedVersion = sanitizeVersion(latestVersion.Number)
latestAvailableVersion.value = { ...latestVersion, Number: sanitizedVersion }
visualStore.setLatestAvailableVersion(latestAvailableVersion.value)
}
@@ -1,31 +1,116 @@
import ObjectLoader from '@speckle/objectloader'
import { ObjectLoader2Factory } from '@speckle/objectloader2'
import { SpeckleLoader, WorldTree } from '@speckle/viewer'
// Base type from objectloader2 (has id, speckle_type properties)
interface Base {
id: string
speckle_type: string
[key: string]: any
}
export class SpeckleObjectsOfflineLoader extends SpeckleLoader {
constructor(targetTree: WorldTree, resourceData: string, resourceId?: string) {
super(targetTree, resourceId || '', undefined, undefined, resourceData)
constructor(targetTree: WorldTree, resourceData: unknown, resourceId?: string) {
// Resource ID is not used for offline loading since we have objects in memory
// Pass empty string to avoid URL parsing issues
super(targetTree, '', undefined, undefined, resourceData)
}
protected initObjectLoader(
_resource: string,
_authToken?: string,
_enableCaching?: boolean,
resourceData?: string | ArrayBuffer
): ObjectLoader {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return ObjectLoader.createFromObjects(resourceData as unknown as [])
resource: string,
authToken?: string,
enableCaching?: boolean,
resourceData?: unknown
): ReturnType<SpeckleLoader['initObjectLoader']> {
// Use ObjectLoader2Factory.createFromObjects for offline/memory-based loading
// The objects array must contain ALL objects (root + all children)
// The first object in the array must be the root object
const objects = (resourceData ?? this._resourceData) as Base[]
if (!objects || objects.length === 0) {
throw new Error('SpeckleObjectsOfflineLoader: No objects provided')
}
// Ensure all objects have an 'id' property
const missingIds = objects.filter((obj) => !obj.id)
if (missingIds.length > 0) {
console.error('Objects missing id property:', missingIds.slice(0, 5))
throw new Error(
`SpeckleObjectsOfflineLoader: ${missingIds.length} objects are missing 'id' property`
)
}
console.log(`Creating offline loader with ${objects.length} objects, root: ${objects[0].id}`)
// Create a Set of all object IDs for quick lookup
const objectIds = new Set(objects.map((obj) => obj.id))
// Check for references to objects that aren't in the array
const missingReferences = new Set<string>()
objects.forEach((obj) => {
// Check all properties for references (objects that look like { referencedId: "xxx" })
Object.values(obj).forEach((value) => {
if (value && typeof value === 'object') {
if ('referencedId' in value && typeof value.referencedId === 'string') {
if (!objectIds.has(value.referencedId)) {
missingReferences.add(value.referencedId)
}
}
}
// Check arrays for references
if (Array.isArray(value)) {
value.forEach((item) => {
if (item && typeof item === 'object' && 'referencedId' in item) {
if (!objectIds.has(item.referencedId)) {
missingReferences.add(item.referencedId)
}
}
})
}
})
})
if (missingReferences.size > 0) {
console.warn(
`⚠️ Found ${missingReferences.size} missing object references:`,
Array.from(missingReferences).slice(0, 10)
)
} else {
console.log('✅ All object references are present')
}
// @ts-ignore - Type compatibility issue between local objectloader2 and viewer's objectloader2
return ObjectLoader2Factory.createFromObjects(objects)
}
public async load(): Promise<boolean> {
const rootObject = await this.loader.getRootObject()
if (!rootObject && this._resource) {
console.error('No root id set!')
if (!rootObject) {
console.error('No root object found!')
return false
}
/** If not id is provided, we make one up based on the root object id */
this._resource = this._resource || `/json/${rootObject.id as string}`
/** Set resource to a fake URL for logging purposes only */
this._resource = this._resource || `/json/${rootObject.baseId as string}`
console.log('Loading objects from memory (offline mode)')
// Call parent load() which will use our ObjectLoader2 to iterate through objects
// Since we're using MemoryDownloader, it won't actually download anything
return super.load()
}
/**
* Clean up the ObjectLoader2 resources
*/
public async dispose(): Promise<void> {
try {
if (this.loader && 'disposeAsync' in this.loader) {
// @ts-ignore - disposeAsync exists on ObjectLoader2
await this.loader.disposeAsync()
console.log('SpeckleObjectsOfflineLoader: ObjectLoader2 disposed')
}
} catch (error) {
console.warn('Error disposing ObjectLoader2 in offline loader:', error)
}
}
}
+110 -21
View File
@@ -1,5 +1,5 @@
import { useVisualStore } from '@src/store/visualStore'
import ObjectLoader from '@speckle/objectloader' // Default import for v1
import { ObjectLoader2Factory } from '@speckle/objectloader2'
interface SpeckleObject {
id: string
@@ -18,39 +18,40 @@ export class SpeckleApiLoader {
this.projectId = projectId
this.token = token
this.headers = {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
async downloadObjectsWithChildren(objectId: string, onProgress?: (loaded: number, total: number) => void): Promise<SpeckleObject[]> {
async downloadObjectsWithChildren(
objectId: string,
onProgress?: (loaded: number, total: number) => void
): Promise<SpeckleObject[]> {
const visualStore = useVisualStore()
visualStore.setLoadingProgress('Initializing object loader', 0)
console.log('Creating ObjectLoader v1 for Power BI environment')
console.log('Creating ObjectLoader v2 for Power BI environment')
// Create ObjectLoader v1 instance - use 'token' not 'authToken'
const loader = new ObjectLoader({
const loader = ObjectLoader2Factory.createFromUrl({
serverUrl: this.serverUrl,
streamId: this.projectId,
objectId: objectId,
objectId,
token: this.token,
options: {
enableCaching: false, // Disable caching for Power BI environment
}
attributeMask: { exclude: ['properties', 'encodedValue'] },
options: { useCache: false }
})
try {
// Get total count for progress tracking
const totalCount = await loader.getTotalObjectCount()
console.log(`Loading ${totalCount} objects using ObjectLoader v1`)
console.log(`Loading ${totalCount} objects using ObjectLoader v2`)
const objects: SpeckleObject[] = []
let loadedCount = 0
// Stream all objects using the async iterator
for await (const obj of loader.getObjectIterator()) {
objects.push(obj as SpeckleObject) // Type assertion since ObjectLoader v1 has different type
objects.push(obj as SpeckleObject) // Type assertion for SpeckleObject interface
loadedCount++
// Update progress
@@ -67,18 +68,107 @@ export class SpeckleApiLoader {
}
}
console.log(`Downloaded ${objects.length} objects using ObjectLoader v1`)
console.log(`Downloaded ${objects.length} objects using ObjectLoader v2`)
visualStore.setLoadingProgress('🔄 Finalizing object download...', 0.9)
// Recursively fetch all missing references until none remain
let iterationCount = 0
let totalFetched = 0
while (iterationCount < 10) {
// Safety limit: loop exits early when missingIds.size === 0 (line 108)
// This limit only prevents infinite loops if something goes wrong
iterationCount++
const objectIds = new Set(objects.map((obj) => obj.id))
const missingIds = new Set<string>()
// Check all objects for missing references
objects.forEach((obj) => {
Object.values(obj).forEach((value) => {
if (value && typeof value === 'object') {
if ('referencedId' in value && typeof value.referencedId === 'string') {
if (!objectIds.has(value.referencedId)) {
missingIds.add(value.referencedId)
}
}
}
if (Array.isArray(value)) {
value.forEach((item) => {
if (item && typeof item === 'object' && 'referencedId' in item) {
if (!objectIds.has(item.referencedId)) {
missingIds.add(item.referencedId)
}
}
})
}
})
})
if (missingIds.size === 0) {
console.log(
`✅ No more missing references. Complete after ${iterationCount} iteration(s)`
)
break
}
console.log(
`Iteration ${iterationCount}: Fetching ${missingIds.size} missing referenced objects...`
)
visualStore.setLoadingProgress(`🔄 Loading additional objects)`, 0.9)
// Fetch missing objects with progress tracking
const missingIdsArray = Array.from(missingIds)
let fetchedInIteration = 0
for (const missingId of missingIdsArray) {
try {
const missingObj = await loader.getObject({ id: missingId })
objects.push(missingObj as SpeckleObject)
totalFetched++
fetchedInIteration++
// Update progress within this iteration
const iterationProgress = fetchedInIteration / missingIdsArray.length
visualStore.setLoadingProgress(
`🔄 Loading objects (${objects.length} loaded)`,
0.9 + iterationProgress * 0.05 // Progress from 0.9 to 0.95
)
} catch (err) {
console.warn(`⚠️ Could not fetch missing object ${missingId}:`, err)
}
}
console.log(
`✅ Iteration ${iterationCount} complete. Fetched ${missingIdsArray.length} objects. Total: ${objects.length}`
)
}
if (iterationCount >= 10) {
console.warn(
'⚠️ Reached maximum iterations for fetching references. Some objects may still be missing.'
)
}
console.log(
`✅ Downloaded total of ${objects.length} objects (${totalFetched} additional references fetched)`
)
visualStore.setLoadingProgress('Download complete', 1)
return objects
} catch (error) {
console.error('Error loading objects:', error)
throw error
} finally {
// ObjectLoader v1 cleanup
if (loader.dispose) {
loader.dispose()
// Clean up the loader resources
try {
await loader.disposeAsync()
console.log('ObjectLoader2 disposed successfully')
} catch (disposeError) {
console.warn('Error disposing ObjectLoader2:', disposeError)
}
}
}
@@ -91,13 +181,12 @@ export class SpeckleApiLoader {
async downloadMultipleModels(objectIds: string[]): Promise<SpeckleObject[][]> {
const allObjects: SpeckleObject[][] = []
for (const objectId of objectIds) {
const objects = await this.downloadObjectsWithChildren(objectId)
allObjects.push(objects)
}
return allObjects
}
}
}
+27 -4
View File
@@ -35,12 +35,19 @@ export interface Hit {
point: { x: number; y: number; z: number }
}
export interface ViewModeOptions {
edges?: boolean
outlineThickness?: number
outlineOpacity?: number
outlineColor?: number
}
export interface IViewerEvents {
ping: (message: string) => void
setSelection: (objectIds: string[]) => void
resetFilter: (objectIds: string[], ghost: boolean, zoom: boolean) => void
filterSelection: (objectIds: string[], ghost: boolean, zoom: boolean) => void
setViewMode: (viewMode: ViewMode) => void
setViewMode: (viewMode: ViewMode, options?: ViewModeOptions) => void
colorObjectsByGroup: (
colorById: {
objectIds: string[]
@@ -138,9 +145,9 @@ export class ViewerHandler {
return
}
public setViewMode(viewMode: ViewMode) {
public setViewMode(viewMode: ViewMode, options?: ViewModeOptions) {
const viewModes = this.viewer.getExtension(ViewModes)
viewModes.setViewMode(viewMode)
viewModes.setViewMode(viewMode, options)
}
public snapshotCameraPositionAndStore = () => {
@@ -231,11 +238,27 @@ 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
// Clean up loader resources after loading is complete
if (loader.dispose) {
await loader.dispose()
}
}
store.setSpeckleViews(speckleViews)
if (store.defaultViewModeInFile) {
this.setViewMode(Number(store.defaultViewModeInFile))
const viewMode = Number(store.defaultViewModeInFile) as ViewMode
// Apply view mode with edges options from store (with safe defaults)
const edgesEnabled = store.edgesEnabled ?? true
const edgesWeight = store.edgesWeight ?? 1
const edgesColor = store.edgesColor ?? 'auto'
const options: ViewModeOptions = {
edges: edgesEnabled,
outlineThickness: edgesWeight,
outlineOpacity: viewMode === ViewMode.PEN ? 1 : 0.75,
outlineColor: edgesColor === 'auto' ? undefined : edgesColor
}
this.setViewMode(viewMode, options)
}
Tracker.dataLoaded({
@@ -62,6 +62,11 @@ export const useVisualStore = defineStore('visualStore', () => {
const cameraPosition = ref<number[]>(undefined)
const defaultViewModeInFile = ref<string>(undefined)
// Edges settings for view modes
const edgesEnabled = ref<boolean>(true)
const edgesWeight = ref<number>(1)
const edgesColor = ref<number | 'auto'>('auto')
const speckleViews = ref<SpeckleView[]>([])
// callback mechanism to viewer to be able to manage input data accordingly.
@@ -111,6 +116,15 @@ export const useVisualStore = defineStore('visualStore', () => {
return false
})
// detecting the env to control the visibility of update button
// might use for different reasons in the future
const isRunningInDesktop = computed(() => {
// power bi hostEnv enum values:
// web = 1, desktop = 4
const hostEnv = host.value?.['hostEnv'] as number
return hostEnv === 4
})
/**
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
* @param emit picky emit function to trigger events under `IViewerEvents` interface
@@ -462,6 +476,37 @@ export const useVisualStore = defineStore('visualStore', () => {
const setCameraPositionInFile = (newValue: number[]) => (cameraPosition.value = newValue)
const setDefaultViewModeInFile = (newValue: string) => (defaultViewModeInFile.value = newValue)
// Edges settings setters
const setEdgesEnabled = (val: boolean) => {
edgesEnabled.value = val
}
const setEdgesWeight = (val: number) => {
edgesWeight.value = val
}
const setEdgesColor = (val: number | 'auto') => {
edgesColor.value = val
}
const writeEdgesSettingsToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unnecessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'viewMode',
properties: {
edgesEnabled: edgesEnabled.value,
edgesWeight: edgesWeight.value,
edgesColor: edgesColor.value === 'auto' ? -1 : edgesColor.value
},
selector: null
}
]
})
}
const setSpeckleViews = (newSpeckleViews: SpeckleView[]) => (speckleViews.value = newSpeckleViews)
const setFormattingSettings = (newFormattingSettings: SpeckleVisualSettingsModel) =>
(formattingSettings.value = newFormattingSettings)
@@ -546,6 +591,9 @@ export const useVisualStore = defineStore('visualStore', () => {
isLoadingFromFile,
cameraPosition,
defaultViewModeInFile,
edgesEnabled,
edgesWeight,
edgesColor,
speckleViews,
postFileSaveSkipNeeded,
postClickSkipNeeded,
@@ -559,6 +607,7 @@ export const useVisualStore = defineStore('visualStore', () => {
isZoomOnFilterActive,
latestAvailableVersion,
isConnectorUpToDate,
isRunningInDesktop,
commonError,
previousToggleState,
setCommonError,
@@ -573,6 +622,10 @@ export const useVisualStore = defineStore('visualStore', () => {
setPostFileSaveSkipNeeded,
setCameraPositionInFile,
setDefaultViewModeInFile,
setEdgesEnabled,
setEdgesWeight,
setEdgesColor,
writeEdgesSettingsToFile,
setSpeckleViews,
loadObjectsFromFile,
setHost,
@@ -0,0 +1,97 @@
/**
* Interface for decoded user info data passed from the data connector
* This data is base64-encoded in the "Version Object ID" field and decoded by the visual
*/
export interface DecodedUserInfo {
rootObjectId: string
server: string
email: string
projectId: string
token: string // weak token with limited scopes
workspaceId?: string | null
workspaceName?: string | null
workspaceLogo?: string | null
version?: string
sourceApplication?: string
canHideBranding?: boolean
versionId?: string
url?: string
}
// Decodes a base64-encoded JSON string to extract userInfoData
export function decodeUserInfo(encodedString: string): DecodedUserInfo {
try {
// Base64 decode using browser's atob()
const decodedString = atob(encodedString)
// Parse JSON
const userInfo = JSON.parse(decodedString) as DecodedUserInfo
// Validate required fields
const requiredFields: (keyof DecodedUserInfo)[] = [
'rootObjectId',
'server',
'email',
'projectId',
'token'
]
const missingFields = requiredFields.filter((field) => !userInfo[field])
if (missingFields.length > 0) {
throw new Error(
`Missing required fields in decoded user info: ${missingFields.join(', ')}`
)
}
return userInfo
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to decode user info: ${error.message}`)
}
throw new Error('Failed to decode user info: Unknown error')
}
}
// Decodes multiple base64-encoded userInfo strings (for federated models)
export function decodeMultipleUserInfo(encodedStrings: string): DecodedUserInfo[] {
try {
// Split by delimiter
const segments = encodedStrings.split('|||')
// Decode each segment
return segments.map((segment, index) => {
try {
return decodeUserInfo(segment.trim())
} catch (error) {
throw new Error(
`Failed to decode segment ${index + 1} of federated model data: ${
error instanceof Error ? error.message : 'Unknown error'
}`
)
}
})
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to decode multiple user info: ${error.message}`)
}
throw new Error('Failed to decode multiple user info: Unknown error')
}
}
// Checks if an encoded string contains multiple models (federated)
export function isFederatedEncoding(encodedString: string): boolean {
return encodedString.includes('|||')
}
// Safely decodes userInfo, handling both single and federated models
// Returns an array of DecodedUserInfo (single item for non-federated)
export function decodeUserInfoSafe(encodedString: string): DecodedUserInfo[] {
if (isFederatedEncoding(encodedString)) {
return decodeMultipleUserInfo(encodedString)
} else {
return [decodeUserInfo(encodedString)]
}
}
+175 -76
View File
@@ -13,6 +13,7 @@ import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
import { SpeckleApiLoader } from '@src/loader/SpeckleApiLoader'
import { unzipModelObjects } from './compression'
import { decodeUserInfoSafe, DecodedUserInfo } from './decodeUserInfo'
export class AsyncPause {
private lastPauseTime = 0
@@ -165,20 +166,71 @@ export type ReceiveInfo = {
projectId?: string
}
async function getReceiveInfo(id) {
/**
* Extracts userInfoData from encoded string
* Returns array of DecodedUserInfo for federated models, single item for single models
*/
function decodeUserInfoFromId(encodedId: string): DecodedUserInfo[] {
try {
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 { desktopServiceError: true }
return decodeUserInfoSafe(encodedId)
} catch (error) {
console.error('Failed to decode user info from encoded ID:', error)
throw new Error(`Invalid encoded user info data: ${error.message}`)
}
}
// Mark version as received
async function markVersionAsReceived(
versionId: string,
projectId: string,
serverUrl: string,
token: string
): Promise<void> {
try {
const mutation = `
mutation MarkVersionReceived($input: MarkReceivedVersionInput!) {
versionMutations {
markReceived(input: $input)
}
}
`
const variables = {
input: {
versionId: versionId,
projectId: projectId,
sourceApplication: 'powerbi'
}
}
return await response.json()
const response = await fetch(`${serverUrl}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
query: mutation,
variables: variables
})
})
if (!response.ok) {
console.warn(
`Failed to mark version as received (status ${response.status}). This is non-critical.`
)
return
}
const result = await response.json()
if (result.errors) {
console.warn('Failed to mark version as received:', result.errors)
} else {
console.log(`✅ Marked version ${versionId} as received in PowerBI`)
}
} catch (error) {
console.log(error)
console.log("User info couldn't retrieved from local server.")
return { desktopServiceError: true }
// Non-critical error - log but don't throw
console.warn('Failed to mark version as received:', error)
}
}
@@ -186,18 +238,25 @@ async function fetchFromSpeckleApi(
objectIds: string,
serverUrl: string,
projectId: string,
token: string
token: string,
versionIds?: string[]
): Promise<object[][]> {
const ids = objectIds.split(',')
const modelObjects = []
for (const objectId of ids) {
for (let i = 0; i < ids.length; i++) {
const objectId = ids[i]
try {
console.log(`Downloading from Speckle API: ${objectId}`)
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
const objects = await loader.downloadObjectsWithChildren(objectId)
modelObjects.push(objects)
console.log(`Downloaded ${objects.length} objects from Speckle`)
// Mark version as received (non-blocking, best effort)
if (versionIds && versionIds[i]) {
markVersionAsReceived(versionIds[i], projectId, serverUrl, token)
}
} catch (error) {
console.error(`Failed to download objects from Speckle:`, error)
throw error
@@ -262,18 +321,27 @@ export async function processMatrixView(
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
// CRITICAL: Validate that internalized data matches current matrix data
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
if (internalizedRootId !== id) {
console.log(
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${id}. Using fresh data.`
)
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
} else {
console.log(
'📁 Successfully validated internalized data matches current matrix:',
internalizedModelObjects.length,
'models'
)
// Need to decode id first to get actual root object IDs for comparison
try {
const decodedForCheck = decodeUserInfoFromId(id)
const actualRootIds = decodedForCheck.map((info) => info.rootObjectId).join(',')
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
if (internalizedRootId !== actualRootIds.split(',')[0]) {
console.log(
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${actualRootIds}. Using fresh data.`
)
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
} else {
console.log(
'📁 Successfully validated internalized data matches current matrix:',
internalizedModelObjects.length,
'models'
)
}
} catch (error) {
console.error('📁 Failed to decode ID for internalized data check:', error)
internalizedModelObjects = undefined
}
}
@@ -295,17 +363,26 @@ export async function processMatrixView(
}
// Only reload if switching models or not already loaded
const needsReload =
!visualStore.isViewerObjectsLoaded || visualStore.lastLoadedRootObjectId !== id
if (needsReload) {
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
visualStore.setViewerReloadNeeded()
visualStore.setViewerReadyToLoad(true)
visualStore.setLoadingProgress('📁 Loading from file', null)
} else {
console.log('📁 Internalized data already loaded, skipping reload')
// Need to decode to get actual root object ID for comparison
try {
const decodedForReload = decodeUserInfoFromId(id)
const actualRootIds = decodedForReload.map((info) => info.rootObjectId).join(',')
const needsReload =
!visualStore.isViewerObjectsLoaded ||
visualStore.lastLoadedRootObjectId !== actualRootIds
if (needsReload) {
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
visualStore.setViewerReloadNeeded()
visualStore.setViewerReadyToLoad(true)
visualStore.setLoadingProgress('📁 Loading from file', null)
} else {
console.log('📁 Internalized data already loaded, skipping reload')
}
visualStore.lastLoadedRootObjectId = actualRootIds // Set to actual root IDs to skip API calls
} catch (error) {
console.error('📁 Failed to decode ID for reload check:', error)
}
visualStore.lastLoadedRootObjectId = id // Set to current ID to skip API calls
} else {
console.error('📁 Failed to unzip internalized data')
}
@@ -314,59 +391,72 @@ export async function processMatrixView(
}
}
// const id = localMatrixView[0].values[0].value as unknown as string
console.log('🗝️ Root Object Id: ', id)
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
// Extract the encoded string from matrix (id is now the base64 encoded userInfo)
const encodedId = id
console.log('🗝️ Encoded ID: ', encodedId.substring(0, 50) + '...')
console.log('Last loaded root object id', visualStore.lastLoadedRootObjectId)
let modelObjects: object[][] = undefined
// Decode userInfo first to get actual root object IDs for comparison
let decodedUserInfos: DecodedUserInfo[]
let actualRootObjectIds: string
try {
decodedUserInfos = decodeUserInfoFromId(encodedId)
// Build comma-separated list of actual root object IDs
actualRootObjectIds = decodedUserInfos.map((info) => info.rootObjectId).join(',')
console.log(`🔓 Decoded ${decodedUserInfos.length} userInfo(s) - Root IDs: ${actualRootObjectIds}`)
} catch (error) {
console.error('Failed to decode user info:', error)
visualStore.setCommonError(
'Failed to decode user info from data connector. Please refresh the data.'
)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
// Check if we need to reload (compare actual root object IDs, not encoded strings)
if (
visualStore.lastLoadedRootObjectId !== id &&
visualStore.lastLoadedRootObjectId !== actualRootObjectIds &&
!visualStore.isLoadingFromFile &&
!internalizedModelObjects
) {
const start = performance.now()
// Get receive info from desktop service to populate visual store
const receiveInfo = await getReceiveInfo(id)
let desktopServiceUnavailable = false
// Use the first decoded userInfo for visual store (for federated, all have same credentials)
const primaryUserInfo = decodedUserInfos[0]
if (receiveInfo && !receiveInfo.desktopServiceError) {
visualStore.setReceiveInfo({
userEmail: receiveInfo.email || receiveInfo.Email,
serverUrl: receiveInfo.server || receiveInfo.Server,
sourceApplication: getSlugFromHostAppNameAndVersion(
receiveInfo.sourceApplication || receiveInfo.SourceApplication
),
workspaceId: receiveInfo.workspaceId || receiveInfo.WorkspaceId,
workspaceName: receiveInfo.workspaceName || receiveInfo.WorkspaceName,
workspaceLogo: receiveInfo.workspaceLogo || receiveInfo.WorkspaceLogo,
version: receiveInfo.version || receiveInfo.Version,
canHideBranding: receiveInfo.canHideBranding ?? receiveInfo.CanHideBranding,
token: receiveInfo.weakToken || receiveInfo.WeakToken,
projectId: receiveInfo.projectId || receiveInfo.ProjectId
})
console.log(`Receive info retrieved from desktop service - credentials loaded`)
} else {
desktopServiceUnavailable = true
console.log('Desktop service unavailable - cannot retrieve credentials')
}
visualStore.setReceiveInfo({
userEmail: primaryUserInfo.email,
serverUrl: primaryUserInfo.server,
sourceApplication: getSlugFromHostAppNameAndVersion(primaryUserInfo.sourceApplication || ''),
workspaceId: primaryUserInfo.workspaceId || undefined,
workspaceName: primaryUserInfo.workspaceName || undefined,
workspaceLogo: primaryUserInfo.workspaceLogo || undefined,
version: primaryUserInfo.version,
canHideBranding: primaryUserInfo.canHideBranding || false,
token: primaryUserInfo.token,
projectId: primaryUserInfo.projectId
})
console.log(`✅ Credentials loaded from encoded data`)
// Now get the data from visual store for Speckle API download
const token = visualStore.receiveInfo?.token
const serverUrl = visualStore.receiveInfo?.serverUrl
const projectId = visualStore.receiveInfo?.projectId
// Get credentials for Speckle API download
const token = primaryUserInfo.token
const serverUrl = primaryUserInfo.server
const projectId = primaryUserInfo.projectId
if (!token || !serverUrl || !projectId) {
if (desktopServiceUnavailable) {
visualStore.setCommonError(
'Speckle Desktop Service is not running. Please start Speckle Desktop Services and refresh data.'
)
} else {
visualStore.setCommonError(
'Missing Speckle credentials. Please refresh the data from the data connector.'
)
}
visualStore.setCommonError(
'Missing required credentials in encoded data. Please refresh the data from the data connector.'
)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
@@ -381,9 +471,18 @@ export async function processMatrixView(
visualStore.setViewerReadyToLoad(true)
console.log('Downloading objects directly from Speckle API...')
console.log(`Server: ${serverUrl}, Project: ${projectId}, Object: ${id}`)
console.log(`Server: ${serverUrl}, Project: ${projectId}, Objects: ${actualRootObjectIds}`)
try {
modelObjects = await fetchFromSpeckleApi(id, serverUrl, projectId, token)
// Extract versionIds for markAsReceived
const versionIds = decodedUserInfos.map((info) => info.versionId).filter(Boolean) as string[]
modelObjects = await fetchFromSpeckleApi(
actualRootObjectIds,
serverUrl,
projectId,
token,
versionIds.length > 0 ? versionIds : undefined
)
console.log('Successfully downloaded from Speckle API')
// Debug: Check what we're passing to the viewer
+1 -1
View File
@@ -21,7 +21,7 @@
<div class="flex flex-col justify-center space-y-1">
<div class="flex flex-row space-x-2">
<EyeIcon class="w-6"></EyeIcon>
<p><b>Version Object ID</b></p>
<p><b>Model Info</b></p>
<ArrowRightIcon class="w-4"></ArrowRightIcon>
<p>View your model</p>
</div>
+46 -70
View File
@@ -22,6 +22,7 @@ import ITooltipService = powerbi.extensibility.ITooltipService
import { pinia } from './plugins/pinia'
import { useVisualStore } from './store/visualStore'
import { SpeckleApiLoader } from './loader/SpeckleApiLoader'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
@@ -184,6 +185,24 @@ export class Visual implements IVisual {
)
}
// Load edges settings
const viewModeSettings = options.dataViews[0].metadata.objects.viewMode
if (viewModeSettings) {
if ('edgesEnabled' in viewModeSettings) {
console.log(`Edges Enabled: ${viewModeSettings.edgesEnabled as boolean}`)
visualStore.setEdgesEnabled(viewModeSettings.edgesEnabled as boolean)
}
if ('edgesWeight' in viewModeSettings) {
console.log(`Edges Weight: ${viewModeSettings.edgesWeight as number}`)
visualStore.setEdgesWeight(viewModeSettings.edgesWeight as number)
}
if ('edgesColor' in viewModeSettings) {
const colorVal = viewModeSettings.edgesColor as number
console.log(`Edges Color: ${colorVal}`)
visualStore.setEdgesColor(colorVal === -1 ? 'auto' : colorVal)
}
}
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
console.log(`Stored camera position is found`)
visualStore.setCameraPositionInFile([
@@ -353,97 +372,54 @@ export class Visual implements IVisual {
}
try {
console.log('📁 Starting internalization via desktop service streaming...')
console.log('📁 Starting internalization via Speckle API...')
visualStore.setLoadingProgress('📦 Internalizing data...', null)
// Use desktop service for internalization
// TBD: getting objects from viewer caused two issue:
// - Data format -> we need to make an extra operation to match with the offline loader
// - Memory -> need to save data two times so sometimes causes memory issues
const rootObjectIds = visualStore.lastLoadedRootObjectId
// Get credentials from visualStore (already loaded from encoded data)
const token = visualStore.receiveInfo?.token
const serverUrl = visualStore.receiveInfo?.serverUrl
const projectId = visualStore.receiveInfo?.projectId
// Handle federated models by processing each object ID separately
const objectIds = rootObjectIds.split(',')
let allStreamedObjects = []
for (const objectId of objectIds) {
console.log(`📁 Fetching objects for ID: ${objectId}`)
// For federated models, pass project ID explicitly to avoid "project id is not set" error
const url = projectId
? `http://localhost:29364/get-objects/${objectId}?projectId=${projectId}`
: `http://localhost:29364/get-objects/${objectId}`
const response = await fetch(url)
if (!response.body) {
console.error(`📁 No response body from desktop service for ${objectId}`)
continue
}
const rootObjectIds = visualStore.lastLoadedRootObjectId
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allObjectsData = ''
console.log(`📁 Streaming objects from desktop service for ${objectId}...`)
while (true) {
const { done, value } = await reader.read()
if (done) break
allObjectsData += decoder.decode(value, { stream: true })
}
// Parse NDJSON (newline-delimited JSON) format
const lines = allObjectsData.trim().split('\n')
const objectsForThisId = lines.map((line) => JSON.parse(line))
console.log(`📁 Streamed ${objectsForThisId.length} objects for ID ${objectId}`)
allStreamedObjects.push(...objectsForThisId)
}
const streamedObjects = allStreamedObjects
if (streamedObjects.length === 0) {
console.error('📁 No objects retrieved from desktop service')
if (!token || !serverUrl || !projectId) {
console.error('📁 Missing credentials for internalization')
visualStore.clearLoadingProgress()
return
}
console.log(`📁 Retrieved ${streamedObjects.length} total objects from desktop service`)
// Clean up objects to reduce file size (same as desktop service does)
const cleanedObjects = streamedObjects.map((obj: any, index: number) => {
// Skip first object (root), clean others
if (index === 0) return obj
// Handle federated models by processing each object ID separately
const objectIds = rootObjectIds.split(',')
let allObjects = []
const cleanedObj = { ...obj }
for (const objectId of objectIds) {
console.log(`📁 Downloading objects for ID: ${objectId}`)
// Remove unnecessary properties
if (cleanedObj.speckle_type?.includes('Objects.Data.DataObject')) {
delete cleanedObj.properties
}
delete cleanedObj.__closure
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
const objects = await loader.downloadObjectsWithChildren(objectId)
return cleanedObj
})
console.log(`📁 Downloaded ${objects.length} objects for ID ${objectId}`)
allObjects.push(objects)
}
console.log(`📁 Cleaned objects: ${cleanedObjects.length} total`)
if (allObjects.length === 0 || allObjects.every((arr) => arr.length === 0)) {
console.error('📁 No objects retrieved from Speckle API')
visualStore.clearLoadingProgress()
return
}
// Wrap in array format expected by viewer (object[][])
const modelObjectsArray = [cleanedObjects]
console.log(`📁 Retrieved ${allObjects.reduce((sum, arr) => sum + arr.length, 0)} total objects from Speckle API`)
// Use existing writeObjectsToFile method from visualStore
visualStore.writeObjectsToFile(modelObjectsArray)
// allObjects is already in the format object[][] expected by viewer
visualStore.writeObjectsToFile(allObjects)
// Clear loading message immediately when done
visualStore.clearLoadingProgress()
console.log('📁 Successfully internalized data via desktop service!')
console.log('📁 Successfully internalized data via Speckle API!')
} catch (error) {
console.error('📁 Failed to internalize via desktop service:', error)
console.error('📁 Failed to internalize via Speckle API:', error)
// Clear loading message immediately on error
visualStore.clearLoadingProgress()