Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c74660eb8e | |||
| a73e832816 | |||
| dba6add26d | |||
| 6ece39d95d | |||
| 0b55013a84 | |||
| baa723287b | |||
| 0976597db3 | |||
| 40536a565f | |||
| 34115d9a5d | |||
| 74ac3e3990 | |||
| f9b5e250d8 | |||
| 0befca0200 | |||
| 1a74336e27 | |||
| 9f9b31d9ba | |||
| df3ad118e1 | |||
| ec634be352 | |||
| 0a4ae9340a | |||
| 92bcf4b5c0 | |||
| 2a22bbf0af | |||
| 7b5e5397b6 | |||
| 24eeb44ff7 | |||
| b1f16c4005 | |||
| 2307d87735 | |||
| b80624396d | |||
| 098ef3d112 | |||
| 94fdc7a2c3 | |||
| 525857bd26 | |||
| 959bcaa671 | |||
| 04b3aef829 | |||
| 318dc6dbbe | |||
| 20577a1fdb | |||
| e74bad829e | |||
| dda04e49c2 | |||
| 97983fb8aa | |||
| 1cac02ae61 | |||
| 0a5001987e | |||
| 5ffb3ea1dd | |||
| 3461c48b11 | |||
| 220946a611 | |||
| 53e4cda456 | |||
| 4ca0ae0978 | |||
| 685a137531 | |||
| 78af91f38a | |||
| 108a406bd5 | |||
| d7ede2edcf | |||
| a25d635ca1 | |||
| 5a9add6d76 | |||
| 89c8005dee | |||
| a384370652 |
@@ -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/')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
(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"),
|
||||
MarkReceived = Extension.LoadFunction("MarkReceived.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
@@ -26,24 +35,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 +92,108 @@
|
||||
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],
|
||||
|
||||
// mark version as received
|
||||
markReceivedResult = MarkReceived(powerfulToken, versionId, parsedUrl[projectId], parsedUrl[baseUrl]),
|
||||
|
||||
// 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) and (markReceivedResult or not markReceivedResult) 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 +202,98 @@
|
||||
{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],
|
||||
federatedVersionId = if versionId <> null then versionId else modelInfo[versionId],
|
||||
|
||||
// mark version as received (non-blocking, best-effort)
|
||||
markReceivedResult = MarkReceived(powerfulToken, federatedVersionId, projectId, baseUrl),
|
||||
|
||||
// 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 and markReceivedResult to force evaluation
|
||||
result = Table.AddColumn(
|
||||
structuredData,
|
||||
"Source Model",
|
||||
each modelName,
|
||||
structuredData,
|
||||
"Source Model",
|
||||
each if versionId <> null then
|
||||
if (markReceivedResult or not markReceivedResult) then Text.Combine({modelName, "-", versionId}) else Text.Combine({modelName, "-", versionId})
|
||||
else if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) 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,8 @@
|
||||
#"Object IDs" = record[id], // Object IDs
|
||||
#"Speckle Type" = record[speckle_type], // Speckle Type
|
||||
#"Version Object ID" = rootId,
|
||||
#"Model Info" = rootId,
|
||||
#"Application ID" = Record.FieldOrDefault(record, "applicationId", null), // Application ID
|
||||
data = cleanedRecord // Data
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Function to mark a version as received via GraphQL mutation
|
||||
// Uses the powerful token
|
||||
(powerfulToken as text, versionId as text, projectId as text, serverUrl as text) as logical =>
|
||||
try
|
||||
let
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// Build GraphQL
|
||||
GraphQLMutation = "mutation MarkVersionReceived($input: MarkReceivedVersionInput!) { versionMutations { markReceived(input: $input) } }",
|
||||
|
||||
Variables = [
|
||||
input = [
|
||||
versionId = versionId,
|
||||
projectId = projectId,
|
||||
sourceApplication = "powerbi-data"
|
||||
]
|
||||
],
|
||||
|
||||
// Make GraphQL request
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = GraphQLMutation,
|
||||
variables = Variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 5)
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status]
|
||||
in
|
||||
StatusCode >= 200 and StatusCode < 300
|
||||
otherwise
|
||||
false
|
||||
@@ -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 =>
|
||||
try let
|
||||
// Import required functions
|
||||
let
|
||||
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),
|
||||
@@ -27,77 +25,75 @@
|
||||
modelInfo = GetModel(url),
|
||||
parsedUrl = Parser(url),
|
||||
userInfo = GetUser(url),
|
||||
powerfulToken = userInfo[Token],
|
||||
|
||||
apiKey = userInfo[Token],
|
||||
|
||||
userEmail = userInfo[UserEmail],
|
||||
|
||||
// get version from Speckle.pq - look GetVersion.pqm
|
||||
connectorVersion = GetVersion(),
|
||||
|
||||
workspaceInfo = GetWorkspace(url),
|
||||
|
||||
// exchange powerful token for weak token via ds
|
||||
tokenExchangeData = Json.FromValue([
|
||||
PowerfulToken = apiKey,
|
||||
Scopes = {"profile:read", "streams:read", "users:read"},
|
||||
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}
|
||||
]
|
||||
// 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]
|
||||
),
|
||||
|
||||
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)
|
||||
// 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]
|
||||
]
|
||||
],
|
||||
|
||||
// downloads data directly from server
|
||||
DirectDownload = (token as text) =>
|
||||
let
|
||||
objectUrl = Text.Combine({
|
||||
parsedUrl[baseUrl],
|
||||
"/objects/",
|
||||
parsedUrl[projectId],
|
||||
"/",
|
||||
modelInfo[rootObjectId]
|
||||
}),
|
||||
|
||||
Response = Web.Contents(
|
||||
objectUrl,
|
||||
[
|
||||
Headers = [
|
||||
#"Authorization" = "Bearer " & token,
|
||||
#"Accept" = "application/json"
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
error [
|
||||
Reason = "DirectDownloadFailed",
|
||||
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
|
||||
Message.Parameters = {Text.From(StatusCode)},
|
||||
Detail = [
|
||||
StatusCode = StatusCode,
|
||||
ObjectUrl = objectUrl,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
RootObjectId = modelInfo[rootObjectId]
|
||||
]
|
||||
]
|
||||
in
|
||||
JsonResponse,
|
||||
|
||||
// download data using the token (weak if exchange succeeded, powerful otherwise)
|
||||
FinalResult = DirectDownload(tokenToUse)
|
||||
|
||||
in
|
||||
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."
|
||||
]
|
||||
FinalResult
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,6 +120,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sectionBox": {
|
||||
"properties": {
|
||||
"boxData": {
|
||||
"type": { "text": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraPosition": {
|
||||
"properties": {
|
||||
"positionX": {
|
||||
@@ -218,7 +234,7 @@
|
||||
{
|
||||
"essential": true,
|
||||
"name": "WebAccess",
|
||||
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
|
||||
"parameters": ["https://analytics.speckle.systems", "*"]
|
||||
},
|
||||
{
|
||||
"essential": false,
|
||||
|
||||
Generated
+3571
-2020
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -5,90 +5,71 @@
|
||||
<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
|
||||
: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>
|
||||
<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)"
|
||||
/>
|
||||
<!-- Views -->
|
||||
<ViewerViewsMenu
|
||||
:open="viewsOpen"
|
||||
<!-- 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>
|
||||
<!-- Camera -->
|
||||
<ViewerCameraMenu
|
||||
:open="cameraOpen"
|
||||
:views="views"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'views' : 'none')"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'camera' : 'none')"
|
||||
@view-clicked="(view) => $emit('view-clicked', view)"
|
||||
/>
|
||||
<!-- Perspective/Ortho -->
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
secondary
|
||||
tooltip="Projection"
|
||||
:active="visualStore.isOrthoProjection"
|
||||
@click="toggleProjection"
|
||||
>
|
||||
<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>
|
||||
<!-- Section box -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="Section box"
|
||||
@click="$emit('update:sectionBox')"
|
||||
>
|
||||
<ScissorsIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<span
|
||||
v-if="sectionBox"
|
||||
class="absolute top-1 right-1 h-2 w-2 rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</ViewerControlsButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowsPointingOutIcon } from '@heroicons/vue/24/solid'
|
||||
import { SpeckleView } from '@speckle/viewer'
|
||||
import { ArrowsPointingOutIcon, ScissorsIcon } from '@heroicons/vue/24/solid'
|
||||
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerControlsButtonGroup from './viewer/controls/ViewerControlsButtonGroup.vue'
|
||||
import ViewerControlsButtonToggle from './viewer/controls/ViewerControlsButtonToggle.vue'
|
||||
|
||||
import ViewerCameraMenu from './viewer/camera/ViewerCameraMenu.vue'
|
||||
import ViewerViewModesMenu from './viewer/view-modes/ViewerViewModesMenu.vue'
|
||||
import ViewerViewsMenu from './viewer/views/ViewerViewsMenu.vue'
|
||||
|
||||
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'
|
||||
import ViewModesIcon from '../components/global/icon/ViewModes.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: CanonicalView | SpeckleView): void
|
||||
(e: 'clear-palette'): void
|
||||
(e: 'view-mode-clicked', viewMode: ViewMode, options: ViewModeOptions): void
|
||||
}>()
|
||||
withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
sectionBox: false
|
||||
})
|
||||
@@ -96,7 +77,7 @@ withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
type ActiveControl =
|
||||
| 'none'
|
||||
| 'viewModes'
|
||||
| 'views'
|
||||
| 'camera'
|
||||
| 'sun'
|
||||
| 'projection'
|
||||
| 'sectionBox'
|
||||
@@ -113,34 +94,6 @@ const toggleActiveControl = (control: ActiveControl) => {
|
||||
activeControl.value = activeControl.value === control ? 'none' : control
|
||||
}
|
||||
|
||||
const toggleProjection = () => {
|
||||
visualStore.viewerEmit('toggleProjection')
|
||||
visualStore.setIsOrthoProjection(!visualStore.isOrthoProjection)
|
||||
visualStore.writeIsOrthoToFile()
|
||||
}
|
||||
|
||||
const toggleGhostHidden = () => {
|
||||
visualStore.setIsGhost(!visualStore.isGhostActive)
|
||||
visualStore.viewerEmit('toggleGhostHidden', visualStore.isGhostActive)
|
||||
visualStore.writeIsGhostToFile()
|
||||
}
|
||||
|
||||
const toggleZoomOnFilter = () => {
|
||||
visualStore.setIsZoomOnFilterActive(!visualStore.isZoomOnFilterActive)
|
||||
visualStore.writeZoomOnFilterToFile()
|
||||
}
|
||||
|
||||
const viewModesOpen = computed({
|
||||
get: () => activeControl.value === 'viewModes',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'viewModes' : 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const viewsOpen = computed({
|
||||
get: () => activeControl.value === 'views',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'views' : 'none'
|
||||
}
|
||||
})
|
||||
const viewModesOpen = computed(() => activeControl.value === 'viewModes')
|
||||
const cameraOpen = computed(() => activeControl.value === 'camera')
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="border">
|
||||
<div>
|
||||
<transition name="slide-fade">
|
||||
<nav
|
||||
v-show="!visualStore.isNavbarHidden"
|
||||
@@ -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"
|
||||
@@ -77,11 +77,12 @@
|
||||
<transition name="slide-left">
|
||||
<ViewerControls
|
||||
v-show="!visualStore.isNavbarHidden"
|
||||
v-model:section-box="bboxActive"
|
||||
:section-box="sectionBoxEnabled"
|
||||
:views="views"
|
||||
class="fixed top-11 left-2 z-30"
|
||||
@update:section-box="onSectionBoxToggle"
|
||||
@view-clicked="(view) => viewerHandler.setView(view)"
|
||||
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
|
||||
@view-mode-clicked="(viewMode, options) => viewerHandler.setViewMode(viewMode, options)"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
@@ -91,6 +92,11 @@
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<div v-if="sectionBoxVisible" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50 flex gap-2">
|
||||
<FormButton size="sm" color="outline" @click="onSectionBoxReset">Reset</FormButton>
|
||||
<FormButton size="sm" @click="onSectionBoxDone">Done</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'"
|
||||
@@ -149,8 +155,11 @@ const tooltipHandler = inject(tooltipHandlerKey)
|
||||
let viewerHandler: ViewerHandler = null
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
let bboxActive = ref(false)
|
||||
let views: Ref<SpeckleView[]> = ref([])
|
||||
type SectionBoxState = 'inactive' | 'editing' | 'applied'
|
||||
const sectionBoxState = ref<SectionBoxState>('inactive')
|
||||
const sectionBoxEnabled = computed(() => sectionBoxState.value !== 'inactive')
|
||||
const sectionBoxVisible = computed(() => sectionBoxState.value === 'editing')
|
||||
const views: Ref<SpeckleView[]> = ref([])
|
||||
|
||||
const isInteractive = computed(
|
||||
() => visualStore.fieldInputState.rootObjectId && visualStore.fieldInputState.objectIds
|
||||
@@ -158,6 +167,41 @@ const isInteractive = computed(
|
||||
|
||||
const goToSpeckleWebsite = () => visualStore.host.launchUrl('https://speckle.systems')
|
||||
|
||||
function disableSectionBox() {
|
||||
sectionBoxState.value = 'inactive'
|
||||
viewerHandler.toggleSectionBox(false)
|
||||
visualStore.writeSectionBoxToFile(null)
|
||||
visualStore.setSectionBoxData(null)
|
||||
}
|
||||
|
||||
function onSectionBoxToggle() {
|
||||
switch (sectionBoxState.value) {
|
||||
case 'inactive':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.toggleSectionBox(true)
|
||||
break
|
||||
case 'editing':
|
||||
onSectionBoxDone()
|
||||
break
|
||||
case 'applied':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.setSectionBoxVisible(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionBoxReset() {
|
||||
disableSectionBox()
|
||||
}
|
||||
|
||||
function onSectionBoxDone() {
|
||||
sectionBoxState.value = 'applied'
|
||||
viewerHandler.setSectionBoxVisible(false)
|
||||
const boxData = viewerHandler.getSectionBoxData()
|
||||
visualStore.setSectionBoxData(boxData)
|
||||
visualStore.writeSectionBoxToFile(boxData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('Viewer Wrapper mounted')
|
||||
viewerHandler = new ViewerHandler()
|
||||
@@ -165,7 +209,14 @@ onMounted(async () => {
|
||||
|
||||
// Set up event listener for object clicks from the FilteredSelectionExtension
|
||||
viewerHandler.emitter.on('objectClicked', handleObjectClicked)
|
||||
|
||||
|
||||
// Sync section box UI state when restored from file
|
||||
viewerHandler.emitter.on('objectsLoaded', () => {
|
||||
if (visualStore.sectionBoxData) {
|
||||
sectionBoxState.value = 'applied'
|
||||
}
|
||||
})
|
||||
|
||||
visualStore.setViewerEmitter(viewerHandler.emit)
|
||||
})
|
||||
|
||||
@@ -195,7 +246,15 @@ async function handleObjectClicked(hit: any, isMultiSelect: boolean, mouseEvent?
|
||||
const ids = selection.map((s) => s.id)
|
||||
await viewerHandler.selectObjects(ids)
|
||||
} else {
|
||||
visualStore.setPostClickSkipNeeded(false)
|
||||
// Only set skip flag if this visual has active selections to clear.
|
||||
// When selectionManager.clear() is called with no active selection,
|
||||
// Power BI won't send a reaction update, so there's nothing to skip.
|
||||
// Setting the flag unconditionally would cause it to eat the NEXT
|
||||
// legitimate Data update (e.g., adding Color By input).
|
||||
const hasActiveSelection = selectionHandler.getCurrentSelection().length > 0
|
||||
if (hasActiveSelection) {
|
||||
visualStore.setPostClickSkipNeeded(true)
|
||||
}
|
||||
tooltipHandler.hide()
|
||||
if (!isMultiSelect) {
|
||||
selectionHandler.clear()
|
||||
|
||||
@@ -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,38 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.5 8.79167L12 12.5833M18.5 8.79167V15.2917L12 19.0833M18.5 8.79167L12 5L5.5 8.79167M12 12.5833L5.5 8.79167M12 12.5833V19.0833M12 19.0833L5.5 15.2917V8.79167"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 15.2917L1.5 17.6251"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 15.2957L22.5 17.629"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5V1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open">
|
||||
<template #trigger-icon>
|
||||
<VideoCameraIcon class="w-5 h-5" />
|
||||
</template>
|
||||
<template #title>Camera</template>
|
||||
<div class="flex flex-col p-1.5 min-w-[180px] space-y-0.5">
|
||||
<ViewerMenuItem
|
||||
label="Orthographic projection"
|
||||
:active="visualStore.isOrthoProjection"
|
||||
@click="toggleProjection"
|
||||
/>
|
||||
<ViewerMenuItem
|
||||
label="Move camera on filter"
|
||||
:active="visualStore.isZoomOnFilterActive"
|
||||
@click="toggleZoomOnFilter"
|
||||
/>
|
||||
<ViewerMenuItem
|
||||
label="Ghost filtered objects"
|
||||
:active="visualStore.isGhostActive"
|
||||
@click="toggleGhostHidden"
|
||||
/>
|
||||
|
||||
<div class="w-full border-b border-outline-2 my-1"></div>
|
||||
|
||||
<div class="text-body-2xs font-semibold text-foreground-2 px-2 py-1">Views</div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="shortcut in viewShortcuts"
|
||||
:key="shortcut.name"
|
||||
:label="shortcut.name"
|
||||
hide-active-tick
|
||||
:active="false"
|
||||
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
|
||||
/>
|
||||
|
||||
<div v-if="views.length !== 0" class="w-full border-b border-outline-2 my-1"></div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
hide-active-tick
|
||||
:active="false"
|
||||
:label="view.name ? view.name : view.id"
|
||||
@click="handleViewChange(view)"
|
||||
/>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VideoCameraIcon } from '@heroicons/vue/24/outline'
|
||||
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerMenu from '../menu/ViewerMenu.vue'
|
||||
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
|
||||
import { ViewShortcuts } from '@src/helpers/viewer/shortcuts/shortcuts'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
views: SpeckleView[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'view-clicked', value: CanonicalView | SpeckleView): void
|
||||
}>()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
const viewShortcuts = Object.values(ViewShortcuts)
|
||||
|
||||
const handleViewChange = (v: CanonicalView | SpeckleView) => {
|
||||
emit('view-clicked', v)
|
||||
}
|
||||
|
||||
const toggleProjection = () => {
|
||||
visualStore.viewerEmit('toggleProjection')
|
||||
visualStore.setIsOrthoProjection(!visualStore.isOrthoProjection)
|
||||
visualStore.writeIsOrthoToFile()
|
||||
}
|
||||
|
||||
const toggleGhostHidden = () => {
|
||||
visualStore.setIsGhost(!visualStore.isGhostActive)
|
||||
visualStore.viewerEmit('toggleGhostHidden', visualStore.isGhostActive)
|
||||
visualStore.writeIsGhostToFile()
|
||||
}
|
||||
|
||||
const toggleZoomOnFilter = () => {
|
||||
visualStore.setIsZoomOnFilterActive(!visualStore.isZoomOnFilterActive)
|
||||
visualStore.writeZoomOnFilterToFile()
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open" title="Views">
|
||||
<template #trigger-icon>
|
||||
<Views class="w-5 h-5" />
|
||||
</template>
|
||||
<template #title>Views</template>
|
||||
<div
|
||||
class="max-h-64 simple-scrollbar overflow-y-auto flex flex-col p-1.5"
|
||||
@mouseenter="cancelCloseTimer"
|
||||
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
|
||||
@focusin="cancelCloseTimer"
|
||||
@focusout="isManuallyOpened ? undefined : startCloseTimer"
|
||||
>
|
||||
<div v-for="shortcut in viewShortcuts" :key="shortcut.name">
|
||||
<ViewerMenuItem
|
||||
:label="shortcut.name"
|
||||
hide-active-tick
|
||||
:active="activeView === shortcut.name.toLowerCase()"
|
||||
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="views.length !== 0" class="w-full border-b my-1"></div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
hide-active-tick
|
||||
:active="activeView === view.id"
|
||||
:label="view.name ? view.name : view.id"
|
||||
@click="handleViewChange(view)"
|
||||
/>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { onUnmounted, ref, computed } from 'vue'
|
||||
import ViewerMenu from '../menu/ViewerMenu.vue'
|
||||
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
|
||||
import Views from '../../global/icon/Views.vue'
|
||||
import { ViewShortcuts } from '../../../helpers/viewer/shortcuts/shortcuts'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
views: SpeckleView[]
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'force-close-others'): void
|
||||
(e: 'view-clicked', value: CanonicalView | SpeckleView)
|
||||
}>()
|
||||
|
||||
// Computed open for v-model
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
// State
|
||||
const isManuallyOpened = ref(false)
|
||||
const activeView = ref<string | null>(null)
|
||||
|
||||
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false
|
||||
},
|
||||
3000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const handleViewChange = (v: CanonicalView | SpeckleView) => {
|
||||
open.value = false
|
||||
emit('view-clicked', v)
|
||||
}
|
||||
|
||||
const viewShortcuts = Object.values(ViewShortcuts)
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseTimer()
|
||||
})
|
||||
</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)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export class FilteredSelectionExtension extends SelectionExtension {
|
||||
if (!selection) {
|
||||
console.log('🎯 No selection, calling super with null')
|
||||
super.onObjectClicked(selection)
|
||||
this.emit(FilteredSelectionEvent.FilteredObjectClicked, null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,6 +118,7 @@ export class FilteredSelectionExtension extends SelectionExtension {
|
||||
} else {
|
||||
// If no valid hits, treat as empty selection
|
||||
super.onObjectClicked(null)
|
||||
this.emit(FilteredSelectionEvent.FilteredObjectClicked, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
FilteringState,
|
||||
CameraController,
|
||||
CanonicalView,
|
||||
SectionTool,
|
||||
SectionOutlines,
|
||||
ViewModes,
|
||||
CameraEvent,
|
||||
SpeckleView,
|
||||
@@ -20,7 +22,7 @@ import { SpeckleObjectsOfflineLoader } from '@src/laoder/SpeckleObjectsOfflineLo
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import { Tracker } from '@src/utils/mixpanel'
|
||||
import { createNanoEvents, Emitter } from 'nanoevents'
|
||||
import { Vector3 } from 'three'
|
||||
import { Box3, Vector3 } from 'three'
|
||||
|
||||
export interface IViewer {
|
||||
/**
|
||||
@@ -35,12 +37,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[]
|
||||
@@ -52,6 +61,8 @@ export interface IViewerEvents {
|
||||
zoomExtends: () => void
|
||||
toggleProjection: () => void
|
||||
toggleGhostHidden: (ghost: boolean) => void
|
||||
toggleSectionBox: (enabled: boolean) => void
|
||||
setSectionBoxVisible: (visible: boolean) => void
|
||||
loadObjects: (objects: object[]) => void
|
||||
objectsLoaded: () => void
|
||||
objectClicked: (hit: Hit | null, isMultiSelect: boolean, mouseEvent?: PointerEvent) => void
|
||||
@@ -68,6 +79,8 @@ export class ViewerHandler {
|
||||
public cameraControls: CameraController
|
||||
public filtering: FilteringExtension
|
||||
public selection: FilteredSelectionExtension
|
||||
public sectionTool: SectionTool
|
||||
public sectionOutlines: SectionOutlines
|
||||
private filteringState: FilteringState
|
||||
|
||||
constructor() {
|
||||
@@ -87,6 +100,8 @@ export class ViewerHandler {
|
||||
this.emitter.on('objectsLoaded', this.handleObjectsLoaded)
|
||||
this.emitter.on('toggleProjection', this.toggleProjection)
|
||||
this.emitter.on('toggleGhostHidden', this.toggleGhostHidden)
|
||||
this.emitter.on('toggleSectionBox', this.toggleSectionBox)
|
||||
this.emitter.on('setSectionBoxVisible', this.setSectionBoxVisible)
|
||||
}
|
||||
|
||||
async init(parent: HTMLElement) {
|
||||
@@ -94,6 +109,8 @@ export class ViewerHandler {
|
||||
this.cameraControls = this.viewer.getExtension(CameraController)
|
||||
this.filtering = this.viewer.getExtension(FilteringExtension)
|
||||
this.selection = this.viewer.getExtension(FilteredSelectionExtension)
|
||||
this.sectionTool = this.viewer.getExtension(SectionTool)
|
||||
this.sectionOutlines = this.viewer.getExtension(SectionOutlines)
|
||||
|
||||
const store = useVisualStore()
|
||||
if (store.isOrthoProjection) {
|
||||
@@ -128,19 +145,70 @@ export class ViewerHandler {
|
||||
}
|
||||
public toggleProjection = () => this.cameraControls.toggleCameras()
|
||||
|
||||
public setView = (view: CanonicalView) => {
|
||||
public setView = (view: CanonicalView | SpeckleView) => {
|
||||
this.cameraControls.setCameraView(view, false)
|
||||
this.snapshotCameraPositionAndStore()
|
||||
}
|
||||
|
||||
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
|
||||
// TODO
|
||||
return
|
||||
public toggleSectionBox = (enabled: boolean) => {
|
||||
this.setSectionEnabled(enabled)
|
||||
if (enabled) {
|
||||
const sceneBox = this.viewer.getRenderer().sceneBox
|
||||
this.sectionTool.setBox(sceneBox)
|
||||
this.sectionTool.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode) {
|
||||
public setSectionBoxVisible = (visible: boolean) => {
|
||||
this.sectionTool.visible = visible
|
||||
}
|
||||
|
||||
private setSectionEnabled(enabled: boolean): void {
|
||||
this.sectionTool.enabled = enabled
|
||||
this.sectionOutlines.enabled = enabled
|
||||
}
|
||||
|
||||
public getSectionBoxData = (): string | null => {
|
||||
if (!this.sectionTool.enabled) return null
|
||||
const { center, halfSize } = this.sectionTool.getBox()
|
||||
const min = new Vector3().copy(center).sub(halfSize)
|
||||
const max = new Vector3().copy(center).add(halfSize)
|
||||
return JSON.stringify({ min, max })
|
||||
}
|
||||
|
||||
public applySectionBox = (boxData: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(boxData)
|
||||
|
||||
// Validate parsed data structure
|
||||
if (!parsed?.min || !parsed?.max) {
|
||||
throw new Error('Invalid section box data: missing min/max properties')
|
||||
}
|
||||
|
||||
const box = new Box3(
|
||||
new Vector3(parsed.min.x, parsed.min.y, parsed.min.z),
|
||||
new Vector3(parsed.max.x, parsed.max.y, parsed.max.z)
|
||||
)
|
||||
|
||||
this.setSectionEnabled(true)
|
||||
this.sectionTool.setBox(box)
|
||||
this.sectionTool.visible = false
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
|
||||
// Force section outlines recomputation after geometry is rendered
|
||||
requestAnimationFrame(() => {
|
||||
this.sectionOutlines.requestUpdate(true)
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to apply section box, disabling feature:', error)
|
||||
this.setSectionEnabled(false)
|
||||
// Visual continues loading normally without section box
|
||||
}
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode, options?: ViewModeOptions) {
|
||||
const viewModes = this.viewer.getExtension(ViewModes)
|
||||
viewModes.setViewMode(viewMode)
|
||||
viewModes.setViewMode(viewMode, options)
|
||||
}
|
||||
|
||||
public snapshotCameraPositionAndStore = () => {
|
||||
@@ -211,6 +279,9 @@ export class ViewerHandler {
|
||||
|
||||
|
||||
public loadObjects = async (modelObjects: object[][]) => {
|
||||
// disable section box before unloading to prevent stale geometry references.
|
||||
// it will be re-applied from store after new objects are loaded (see applySectionBox below).
|
||||
this.toggleSectionBox(false)
|
||||
await this.viewer.unloadAll()
|
||||
// const stringifiedObject = JSON.stringify(objects)
|
||||
|
||||
@@ -231,11 +302,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({
|
||||
@@ -257,6 +344,10 @@ export class ViewerHandler {
|
||||
this.cameraControls.setCameraView({ position, target }, true)
|
||||
}
|
||||
|
||||
if (store.sectionBoxData) {
|
||||
this.applySectionBox(store.sectionBoxData)
|
||||
}
|
||||
|
||||
// Emit objects loaded event to trigger update
|
||||
this.emit('objectsLoaded')
|
||||
}
|
||||
@@ -318,8 +409,8 @@ const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
|
||||
viewer.createExtension(HybridCameraController) // camera controller
|
||||
viewer.createExtension(FilteringExtension) // filtering - must be created before FilteredSelectionExtension
|
||||
viewer.createExtension(FilteredSelectionExtension) // filtered selection helper - depends on FilteringExtension
|
||||
// viewer.createExtension(SectionTool) // section tool, possibly not needed for now?
|
||||
// viewer.createExtension(SectionOutlines) // section tool, possibly not needed for now?
|
||||
viewer.createExtension(SectionTool) // section tool
|
||||
viewer.createExtension(SectionOutlines) // section outlines
|
||||
// viewer.createExtension(MeasurementsExtension) // measurements, possibly not needed for now?
|
||||
viewer.createExtension(ViewModes) // view modes
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
|
||||
const cameraPosition = ref<number[]>(undefined)
|
||||
const defaultViewModeInFile = ref<string>(undefined)
|
||||
const sectionBoxData = 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[]>([])
|
||||
|
||||
@@ -111,6 +117,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
|
||||
@@ -139,6 +154,23 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const filterColorByIdsForSelection = (colorByIds: ColorBy[] | null | undefined, selectedIds: string[]): ColorBy[] => {
|
||||
return colorByIds?.filter(colorGroup => {
|
||||
const filteredObjectIds = colorGroup.objectIds.filter(objId =>
|
||||
selectedIds.includes(objId)
|
||||
)
|
||||
if (filteredObjectIds.length > 0) {
|
||||
return { ...colorGroup, objectIds: filteredObjectIds }
|
||||
}
|
||||
return false
|
||||
}).map(colorGroup => ({
|
||||
...colorGroup,
|
||||
objectIds: colorGroup.objectIds.filter(objId =>
|
||||
selectedIds.includes(objId)
|
||||
)
|
||||
})) || []
|
||||
}
|
||||
|
||||
const clearLoadingProgress = () => {
|
||||
loadingProgress.value = undefined
|
||||
}
|
||||
@@ -195,6 +227,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
if (dataInput.value.selectedIds.length > 0) {
|
||||
isFilterActive.value = true
|
||||
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
|
||||
|
||||
// When filtering, only apply colors to the selected/isolated objects
|
||||
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
|
||||
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
|
||||
} else {
|
||||
isFilterActive.value = false
|
||||
latestColorBy.value = dataInput.value.colorByIds
|
||||
@@ -205,8 +241,9 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
// No object IDs provided - show all objects without any filtering
|
||||
viewerEmit.value('unIsolateObjects')
|
||||
}
|
||||
// When not filtering, apply all colors including conditional formatting
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
|
||||
const writeObjectsToFile = (modelObjects: object[][]) => {
|
||||
@@ -394,6 +431,23 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const writeSectionBoxToFile = (boxData: string | null) => {
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'sectionBox',
|
||||
properties: {
|
||||
boxData: boxData
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const setSectionBoxData = (newValue: string | null) => (sectionBoxData.value = newValue)
|
||||
|
||||
const setFieldInputState = (newFieldInputState: FieldInputState) =>
|
||||
(fieldInputState.value = newFieldInputState)
|
||||
|
||||
@@ -440,6 +494,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)
|
||||
@@ -452,6 +537,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
// No object IDs provided - show all objects without any filtering
|
||||
viewerEmit.value('unIsolateObjects')
|
||||
}
|
||||
// When resetting filters, apply all colors including conditional formatting
|
||||
if (latestColorBy.value !== null) {
|
||||
viewerEmit.value('colorObjectsByGroup', latestColorBy.value)
|
||||
}
|
||||
@@ -477,6 +563,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
if (dataInput.value.selectedIds.length > 0) {
|
||||
isFilterActive.value = true
|
||||
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
|
||||
|
||||
// When filtering, only apply colors to the selected/isolated objects
|
||||
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
|
||||
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
|
||||
} else {
|
||||
isFilterActive.value = false
|
||||
latestColorBy.value = dataInput.value.colorByIds
|
||||
@@ -487,10 +577,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
// No object IDs provided - show all objects without any filtering
|
||||
viewerEmit.value('unIsolateObjects')
|
||||
}
|
||||
|
||||
// Restore color grouping for all objects when not filtering
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
|
||||
// Restore color grouping
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
|
||||
// Trigger host data refresh to synchronize with Power BI
|
||||
@@ -519,6 +609,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isLoadingFromFile,
|
||||
cameraPosition,
|
||||
defaultViewModeInFile,
|
||||
sectionBoxData,
|
||||
edgesEnabled,
|
||||
edgesWeight,
|
||||
edgesColor,
|
||||
speckleViews,
|
||||
postFileSaveSkipNeeded,
|
||||
postClickSkipNeeded,
|
||||
@@ -532,6 +626,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isZoomOnFilterActive,
|
||||
latestAvailableVersion,
|
||||
isConnectorUpToDate,
|
||||
isRunningInDesktop,
|
||||
commonError,
|
||||
previousToggleState,
|
||||
setCommonError,
|
||||
@@ -546,6 +641,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
setPostFileSaveSkipNeeded,
|
||||
setCameraPositionInFile,
|
||||
setDefaultViewModeInFile,
|
||||
setEdgesEnabled,
|
||||
setEdgesWeight,
|
||||
setEdgesColor,
|
||||
writeEdgesSettingsToFile,
|
||||
setSpeckleViews,
|
||||
loadObjectsFromFile,
|
||||
setHost,
|
||||
@@ -559,6 +658,8 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
writeIsOrthoToFile,
|
||||
writeViewModeToFile,
|
||||
writeCameraPositionToFile,
|
||||
writeSectionBoxToFile,
|
||||
setSectionBoxData,
|
||||
writeHideBrandingToFile,
|
||||
writeNavbarVisibilityToFile,
|
||||
writeDataLoadingModeToFile,
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -131,7 +132,8 @@ function processObjectNode(
|
||||
console.log('⚠️ HAS objects', color)
|
||||
if (color) {
|
||||
res.color = color
|
||||
res.shouldColor = true
|
||||
// Don't override shouldColor for conditional formatting - keep the selection state
|
||||
// res.shouldColor = true // REMOVED: This was overriding cross-filter selection state
|
||||
}
|
||||
}
|
||||
return res
|
||||
@@ -164,19 +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 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'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
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.")
|
||||
// Non-critical error - log but don't throw
|
||||
console.warn('Failed to mark version as received:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,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
|
||||
@@ -231,7 +292,11 @@ export async function processMatrixView(
|
||||
|
||||
try {
|
||||
if (hasColorFilter) {
|
||||
if (!localMatrixView[0].children || localMatrixView[0].children.length === 0 || !localMatrixView[0].children[0].values) {
|
||||
if (
|
||||
!localMatrixView[0].children ||
|
||||
localMatrixView[0].children.length === 0 ||
|
||||
!localMatrixView[0].children[0].values
|
||||
) {
|
||||
throw new Error('Matrix view structure is incomplete for color filter mode')
|
||||
}
|
||||
id = localMatrixView[0].children[0].values[0].value as unknown as string
|
||||
@@ -256,21 +321,31 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
|
||||
|
||||
// Set dummy receiveInfo to prevent UI errors
|
||||
if (!visualStore.receiveInfo) {
|
||||
visualStore.setReceiveInfo({
|
||||
@@ -288,16 +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')
|
||||
}
|
||||
@@ -306,47 +391,71 @@ 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)
|
||||
if (receiveInfo) {
|
||||
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`)
|
||||
}
|
||||
// Use the first decoded userInfo for visual store (for federated, all have same credentials)
|
||||
const primaryUserInfo = decodedUserInfos[0]
|
||||
|
||||
// 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
|
||||
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`)
|
||||
|
||||
// Get credentials for Speckle API download
|
||||
const token = primaryUserInfo.token
|
||||
const serverUrl = primaryUserInfo.server
|
||||
const projectId = primaryUserInfo.projectId
|
||||
|
||||
if (!token || !serverUrl || !projectId) {
|
||||
visualStore.setCommonError(
|
||||
'Missing Speckle credentials. Please refresh the data from the data connector.'
|
||||
'Missing required credentials in encoded data. Please refresh the data from the data connector.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return {
|
||||
@@ -362,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
|
||||
@@ -456,6 +574,7 @@ export async function processMatrixView(
|
||||
localMatrixView.forEach((obj) => {
|
||||
const processedObjectIdLevels = processObjectIdLevel(obj, host, matrixView)
|
||||
|
||||
// Apply conditional formatting color if present, regardless of selection state
|
||||
if (processedObjectIdLevels.color) {
|
||||
let group = colorByIds.find((g) => g.color === processedObjectIdLevels.color)
|
||||
if (!group) {
|
||||
@@ -465,7 +584,11 @@ export async function processMatrixView(
|
||||
}
|
||||
colorByIds.push(group)
|
||||
}
|
||||
// Always add to color group if color is specified (conditional formatting)
|
||||
group.objectIds.push(processedObjectIdLevels.id)
|
||||
} else if (processedObjectIdLevels.shouldColor) {
|
||||
// Only use shouldColor flag when there's no conditional formatting
|
||||
// This preserves the original cross-filter coloring behavior
|
||||
}
|
||||
|
||||
objectIds.push(processedObjectIdLevels.id)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -148,54 +149,64 @@ export class Visual implements IVisual {
|
||||
console.log('🔍 Checking for other saved settings:')
|
||||
|
||||
if (!visualStore.isViewerObjectsLoaded && options.dataViews[0].metadata.objects) {
|
||||
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
|
||||
console.log(
|
||||
`Default View Mode: ${
|
||||
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
|
||||
}`
|
||||
)
|
||||
const defaultViewMode = options.dataViews[0].metadata.objects.viewMode?.defaultViewMode
|
||||
if (defaultViewMode) {
|
||||
console.log(`Default View Mode: ${defaultViewMode as string}`)
|
||||
|
||||
visualStore.setDefaultViewModeInFile(
|
||||
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
|
||||
)
|
||||
visualStore.setDefaultViewModeInFile(defaultViewMode as string)
|
||||
}
|
||||
|
||||
if (options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean) {
|
||||
console.log(
|
||||
`Branding Hidden: ${
|
||||
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
|
||||
}`
|
||||
)
|
||||
const brandingHidden = options.dataViews[0].metadata.objects.workspace?.brandingHidden
|
||||
if (brandingHidden !== undefined) {
|
||||
console.log(`Branding Hidden: ${brandingHidden as boolean}`)
|
||||
|
||||
visualStore.setBrandingHidden(
|
||||
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
|
||||
)
|
||||
visualStore.setBrandingHidden(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
|
||||
}`
|
||||
)
|
||||
const navbarHidden = options.dataViews[0].metadata.objects.viewMode?.navbarHidden
|
||||
if (navbarHidden !== undefined) {
|
||||
console.log(`Navbar Hidden: ${navbarHidden as boolean}`)
|
||||
|
||||
visualStore.setNavbarHidden(
|
||||
options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean
|
||||
)
|
||||
visualStore.setNavbarHidden(navbarHidden as boolean)
|
||||
}
|
||||
|
||||
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
|
||||
console.log(`Stored camera position is found`)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
const cameraPositionData = options.dataViews[0].metadata.objects.cameraPosition
|
||||
if (cameraPositionData?.positionX) {
|
||||
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),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionZ),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetX),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetY),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetZ)
|
||||
Number(cameraPositionData.positionX),
|
||||
Number(cameraPositionData.positionY),
|
||||
Number(cameraPositionData.positionZ),
|
||||
Number(cameraPositionData.targetX),
|
||||
Number(cameraPositionData.targetY),
|
||||
Number(cameraPositionData.targetZ)
|
||||
])
|
||||
}
|
||||
|
||||
const sectionBoxData = options.dataViews[0].metadata.objects.sectionBox?.boxData
|
||||
if (sectionBoxData) {
|
||||
console.log('Stored section box is found')
|
||||
visualStore.setSectionBoxData(sectionBoxData as string)
|
||||
}
|
||||
|
||||
const camera = options.dataViews[0].metadata.objects.camera
|
||||
|
||||
if (camera && 'isOrtho' in camera) {
|
||||
@@ -353,97 +364,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()
|
||||
|
||||
Reference in New Issue
Block a user