Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fd34831c | |||
| dbac5c013b | |||
| a73e832816 | |||
| 0b55013a84 | |||
| baa723287b | |||
| 0976597db3 | |||
| 40536a565f | |||
| 34115d9a5d | |||
| 74ac3e3990 | |||
| f9b5e250d8 | |||
| 0befca0200 | |||
| 1a74336e27 | |||
| 9f9b31d9ba | |||
| df3ad118e1 | |||
| ec634be352 | |||
| 0a4ae9340a | |||
| 92bcf4b5c0 | |||
| 2a22bbf0af | |||
| 7b5e5397b6 | |||
| 24eeb44ff7 | |||
| b1f16c4005 | |||
| 2307d87735 | |||
| b80624396d | |||
| 098ef3d112 |
@@ -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/')
|
||||
|
||||
@@ -71,7 +71,7 @@ GeneratePKCEVerifier = () =>
|
||||
GeneratePKCEChallenge = (verifier as text) =>
|
||||
let
|
||||
// Create SHA256 hash of the verifier as required by RFC 7636
|
||||
hash = Crypto.CreateHash(CryptoAlgorithm.SHA256, Text.ToBinary(verifier, TextEncoding.Ascii)),
|
||||
hash = Crypto.CreateHash(CryptoAlgorithm.SHA256, Text.ToBinary(verifier, TextEncoding.Utf8)),
|
||||
// Convert to base64url encoding
|
||||
challenge = Base64UrlEncode(hash)
|
||||
in
|
||||
@@ -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"),
|
||||
@@ -266,17 +272,27 @@ Speckle = [
|
||||
// Generate PKCE parameters for enhanced security
|
||||
codeVerifier = GeneratePKCEVerifier(),
|
||||
codeChallenge = GeneratePKCEChallenge(codeVerifier),
|
||||
// Build authorization URL with PKCE parameters
|
||||
authUrl = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/") &
|
||||
"?code_challenge=" & codeChallenge &
|
||||
"&code_challenge_method=S256"
|
||||
// Detect if server supports /oauth/token
|
||||
oauthCheck = try Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[ManualStatusHandling = {400, 401, 403, 404, 405, 500}]
|
||||
) otherwise null,
|
||||
useNewOAuth = oauthCheck <> null and Value.Metadata(oauthCheck)[Response.Status] = 200,
|
||||
// Build auth URL based on server capabilities
|
||||
authUrl = if useNewOAuth then
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeChallenge}, "/") &
|
||||
"?code_challenge_method=S256" &
|
||||
"&pbiNew=true"
|
||||
else
|
||||
// Legacy
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeVerifier}, "/")
|
||||
in
|
||||
[
|
||||
LoginUri = authUrl,
|
||||
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
|
||||
WindowHeight = 800,
|
||||
WindowWidth = 600,
|
||||
Context = [code_verifier = codeVerifier]
|
||||
Context = [code_verifier = codeVerifier, use_new_oauth = useNewOAuth]
|
||||
],
|
||||
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
|
||||
let
|
||||
@@ -284,24 +300,35 @@ Speckle = [
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Parts = Uri.Parts(callbackUri)[Query],
|
||||
// Extract code verifier from context for PKCE
|
||||
codeVerifier = if context <> null then context[code_verifier] else null,
|
||||
// Build token request with PKCE parameters
|
||||
tokenRequest = [
|
||||
accessCode = Parts[access_code],
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
challenge = state
|
||||
] & (if codeVerifier <> null then [code_verifier = codeVerifier] else []),
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(tokenRequest)
|
||||
]
|
||||
),
|
||||
useNewOAuth = if context <> null and Record.HasFields(context, "use_new_oauth") then context[use_new_oauth] else false,
|
||||
// Single token exchange call based on server capability
|
||||
Source = if useNewOAuth then
|
||||
Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
accessCode = Parts[access_code],
|
||||
codeVerifier = codeVerifier
|
||||
])
|
||||
]
|
||||
)
|
||||
else
|
||||
// Legacy
|
||||
Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
accessCode = Parts[access_code],
|
||||
challenge = codeVerifier
|
||||
])
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
in
|
||||
[
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
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) =>
|
||||
@@ -29,24 +35,55 @@
|
||||
|
||||
// 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],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// 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
|
||||
@@ -56,29 +93,39 @@
|
||||
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
|
||||
|
||||
// replace both columns with combined values
|
||||
transformedData = Table.TransformColumns(
|
||||
combinedData,
|
||||
{"Version Object ID", each combinedRootIds}
|
||||
{
|
||||
{"Version Object ID", each combinedRootIds},
|
||||
{"Model Info", each combinedEncodedUserInfos}
|
||||
}
|
||||
),
|
||||
|
||||
// expand properties column if requested and if it exists
|
||||
@@ -94,25 +141,60 @@
|
||||
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",
|
||||
renamedData = 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(renamedData, {"properties"}) then
|
||||
result = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
|
||||
try
|
||||
Speckle.Utils.ExpandRecord(renamedData, "properties")
|
||||
Speckle.Utils.ExpandRecord(transformedData, "properties")
|
||||
otherwise
|
||||
renamedData // fallback to original data if expansion fails
|
||||
transformedData // fallback to original data if expansion fails
|
||||
else
|
||||
renamedData
|
||||
transformedData
|
||||
in
|
||||
result
|
||||
else
|
||||
@@ -121,33 +203,89 @@
|
||||
{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),
|
||||
|
||||
|
||||
// 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 if versionId <> null then
|
||||
Text.Combine({modelName, "-", versionId})
|
||||
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
|
||||
@@ -155,7 +293,8 @@
|
||||
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,135 @@
|
||||
// Function to exchange powerful token for weak limited token
|
||||
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text, optional resourceIdString as text) as record =>
|
||||
let
|
||||
// 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,
|
||||
|
||||
// Ensure serverUrl ends with /
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// New Share Token API mutation with variables
|
||||
NewGraphQLQuery = "mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
|
||||
sharingMutations {
|
||||
createEmbedShareToken(input: $input) {
|
||||
token
|
||||
}
|
||||
}
|
||||
}",
|
||||
NewGraphQLVariables = [
|
||||
input = [
|
||||
projectId = projectId,
|
||||
resourceIdString = resourceIdString
|
||||
]
|
||||
],
|
||||
|
||||
// Legacy apiTokenCreate mutation with variables
|
||||
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
|
||||
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
|
||||
LegacyGraphQLQuery = "mutation CreateApiToken($token: ApiTokenCreateInput!) {
|
||||
apiTokenCreate(token: $token)
|
||||
}",
|
||||
LegacyGraphQLVariables = [
|
||||
token = [
|
||||
name = TokenName,
|
||||
scopes = scopes,
|
||||
lifespan = TokenLifespanMs,
|
||||
limitResources = {[
|
||||
type = "project",
|
||||
id = projectId
|
||||
]}
|
||||
]
|
||||
],
|
||||
|
||||
// Helper: execute a GraphQL query with variables and extract token
|
||||
ExecuteGraphQL = (query as text, variables as record, extractToken as function) =>
|
||||
let
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = query,
|
||||
variables = variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 10)
|
||||
]
|
||||
),
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
null,
|
||||
HasErrors = JsonResponse <> null and Record.HasFields(JsonResponse, {"errors"}),
|
||||
Token = if JsonResponse <> null and not HasErrors then
|
||||
try extractToken(JsonResponse) otherwise null
|
||||
else
|
||||
null,
|
||||
ErrorMsg = if HasErrors then
|
||||
try JsonResponse[errors]{0}[message] otherwise "GraphQL mutation failed"
|
||||
else if JsonResponse = null then
|
||||
"Request failed with status " & Number.ToText(StatusCode)
|
||||
else
|
||||
null
|
||||
in
|
||||
[Success = Token <> null, Token = Token, ErrorMessage = ErrorMsg],
|
||||
|
||||
// Try new API first, fall back to legacy
|
||||
Result = if ValidationError <> null then
|
||||
[Success = false, Token = null, ErrorMessage = ValidationError]
|
||||
else
|
||||
let
|
||||
newResult = if resourceIdString <> null then
|
||||
try ExecuteGraphQL(
|
||||
NewGraphQLQuery,
|
||||
NewGraphQLVariables,
|
||||
each [data][sharingMutations][createEmbedShareToken][token]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "New API request failed"]
|
||||
else
|
||||
[Success = false, Token = null, ErrorMessage = null],
|
||||
|
||||
finalResult = if newResult[Success] then
|
||||
newResult
|
||||
else
|
||||
try ExecuteGraphQL(
|
||||
LegacyGraphQLQuery,
|
||||
LegacyGraphQLVariables,
|
||||
each [data][apiTokenCreate]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "Token exchange request failed"]
|
||||
in
|
||||
finalResult
|
||||
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
|
||||
@@ -54,5 +54,6 @@
|
||||
modelId = if isFederated then null else processedModels{0}[modelId],
|
||||
versionId = if isFederated then null else processedModels{0}[versionId],
|
||||
isFederated = isFederated,
|
||||
federatedModels = if isFederated then processedModels else null
|
||||
federatedModels = if isFederated then processedModels else null,
|
||||
resourceIdString = rawModelSegment
|
||||
]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Function for getting issues from Speckle projects, models, or versions
|
||||
(url as text, optional getReplies as logical) as table =>
|
||||
let
|
||||
// Import required functions
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
|
||||
// Set default value for getReplies parameter
|
||||
getRepliesValue = if getReplies = null then false else getReplies,
|
||||
|
||||
// Extension.LoadFunction logic for importing functions from other files
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
asText = Text.FromBinary(binary)
|
||||
in
|
||||
try
|
||||
Expression.Evaluate(asText, #shared)
|
||||
catch (e) =>
|
||||
error
|
||||
[
|
||||
Reason = "Extension.LoadFunction Failure",
|
||||
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
|
||||
Message.Parameters = {fileName, e[Reason], e[Message]},
|
||||
Detail = [File = fileName, Error = e]
|
||||
],
|
||||
|
||||
// Parse the URL to get necessary components with fallback for project-only URLs
|
||||
parsedUrl = try Parser(url) otherwise
|
||||
// Custom parsing for project-only URLs
|
||||
let
|
||||
urlParts = Uri.Parts(url),
|
||||
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
|
||||
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
|
||||
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
|
||||
then pathSegments{1} else null
|
||||
in
|
||||
if projectId = null then
|
||||
error [
|
||||
Reason = "Invalid URL",
|
||||
Message = "The URL must be a valid Speckle project URL in the format 'https://server/projects/PROJECT_ID' or include models/versions"
|
||||
]
|
||||
else
|
||||
[
|
||||
baseUrl = baseUrl,
|
||||
projectId = projectId,
|
||||
modelId = null,
|
||||
versionId = null
|
||||
],
|
||||
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
modelId = parsedUrl[modelId],
|
||||
versionId = parsedUrl[versionId],
|
||||
|
||||
// Define the GraphQL query (single query for all scopes)
|
||||
issuesQuery = "query Project($projectId: String!, $input: ProjectIssuesInput" &
|
||||
(if getRepliesValue then ", $repliesInput2: IssueRepliesInput" else "") & ") {
|
||||
project(id: $projectId) {
|
||||
issues(input: $input) {
|
||||
items {
|
||||
identifier
|
||||
title
|
||||
rawDescription
|
||||
status
|
||||
priority
|
||||
assignee {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
dueDate
|
||||
labels {
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
resourceIdString
|
||||
viewerState
|
||||
id" &
|
||||
(if getRepliesValue then "
|
||||
replies(input: $repliesInput2) {
|
||||
items {
|
||||
issueId
|
||||
id
|
||||
rawDescription
|
||||
createdAt
|
||||
author {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}" else "") & "
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
|
||||
// Build input variable dynamically based on URL scope
|
||||
inputVariable =
|
||||
if versionId <> null then
|
||||
// Version URL: resourceIdString = "MODEL_ID@VERSION_ID"
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId & "@" & versionId
|
||||
]
|
||||
else if modelId <> null then
|
||||
// Model URL: resourceIdString = MODEL_ID
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId
|
||||
]
|
||||
else
|
||||
// Project URL: no resourceIdString
|
||||
[
|
||||
limit = 10000
|
||||
],
|
||||
|
||||
// Build query variables
|
||||
queryVariables = if getRepliesValue then
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable,
|
||||
repliesInput2 = [limit = 10000]
|
||||
]
|
||||
else
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable
|
||||
],
|
||||
|
||||
// Make the API request using ApiFetch
|
||||
result = ApiFetch(server, issuesQuery, queryVariables),
|
||||
|
||||
// Extract issues from the response
|
||||
issues = result[project][issues][items],
|
||||
|
||||
// Transform to table structure with specified columns
|
||||
issuesTable = Table.FromRecords(
|
||||
List.Transform(issues, (issue) =>
|
||||
let
|
||||
// Extract selectedObjectApplicationIds from viewerState (already a record object)
|
||||
viewerState = try issue[viewerState] otherwise null,
|
||||
selectedObjectIds = try viewerState[ui][filters][selectedObjectApplicationIds] otherwise null,
|
||||
objectIds = try Record.FieldNames(selectedObjectIds) otherwise null,
|
||||
applicationIds = try Record.FieldValues(selectedObjectIds) otherwise null,
|
||||
|
||||
baseRecord = [
|
||||
ID = issue[identifier],
|
||||
Title = issue[title],
|
||||
Description = try issue[rawDescription] otherwise null,
|
||||
Status = try issue[status] otherwise null,
|
||||
Priority = try issue[priority] otherwise null,
|
||||
Assignee = try issue[assignee][user][name] otherwise null,
|
||||
#"Due Date" = try DateTime.From(issue[dueDate]) otherwise null,
|
||||
Labels = try List.Transform(issue[labels], each _[name]) otherwise {},
|
||||
#"Created at" = try DateTime.From(issue[createdAt]) otherwise null,
|
||||
#"Updated at" = try DateTime.From(issue[updatedAt]) otherwise null,
|
||||
URL = server & "/projects/" & projectId & "/models/" & issue[resourceIdString] & "#threadId=" & issue[id],
|
||||
#"Object IDs" = objectIds,
|
||||
#"Application IDs" = applicationIds
|
||||
],
|
||||
recordWithReplies = if getRepliesValue then
|
||||
baseRecord & [Replies = try issue[replies][items] otherwise null]
|
||||
else
|
||||
baseRecord
|
||||
in
|
||||
recordWithReplies
|
||||
)
|
||||
)
|
||||
in
|
||||
issuesTable
|
||||
@@ -1,13 +1,11 @@
|
||||
(url as text) as list =>
|
||||
let
|
||||
// Import required functions
|
||||
GetModel = Extension.LoadFunction("GetModel.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
GetUser = Extension.LoadFunction("GetUser.pqm"),
|
||||
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
|
||||
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
|
||||
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
// helper function to load .pqm modules dynamically
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
@@ -24,130 +22,69 @@
|
||||
Detail = [File = fileName, Error = e]
|
||||
],
|
||||
|
||||
// Get required information
|
||||
modelInfo = GetModel(url),
|
||||
parsedUrl = Parser(url),
|
||||
userInfo = GetUser(url),
|
||||
apiKey = userInfo[Token],
|
||||
userEmail = userInfo[UserEmail],
|
||||
connectorVersion = GetVersion(),
|
||||
workspaceInfo = GetWorkspace(url),
|
||||
powerfulToken = userInfo[Token],
|
||||
|
||||
// Function to check if Desktop Service is available
|
||||
IsDesktopServiceAvailable = () =>
|
||||
try
|
||||
let
|
||||
PingResponse = Web.Contents(
|
||||
"http://127.0.0.1:29364/ping",
|
||||
[
|
||||
Headers = [#"Method" = "GET"],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 2) // 2 second timeout for ping
|
||||
]
|
||||
),
|
||||
StatusCode = Value.Metadata(PingResponse)[Response.Status]
|
||||
in
|
||||
StatusCode = 200
|
||||
otherwise
|
||||
false,
|
||||
// exchange powerful token for weak token using GraphQL
|
||||
// this replaces the desktop service token exchange
|
||||
tokenExchangeResult = ExchangeToken(
|
||||
powerfulToken,
|
||||
{"profile:read", "streams:read", "users:read"},
|
||||
parsedUrl[projectId],
|
||||
parsedUrl[baseUrl],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// Function to use Desktop Service approach (only called if available)
|
||||
UseDesktopService = () =>
|
||||
let
|
||||
// exchange powerful token for weak token via ds
|
||||
tokenExchangeData = Json.FromValue([
|
||||
PowerfulToken = apiKey,
|
||||
Scopes = {"profile:read", "streams:read", "users:read"},
|
||||
// throw error if token exchange failed - do NOT use powerful token as fallback
|
||||
tokenToUse = if tokenExchangeResult[Success] then
|
||||
tokenExchangeResult[Token]
|
||||
else
|
||||
error [
|
||||
Reason = "TokenExchangeFailed",
|
||||
Message.Format = "Failed to exchange token for limited scope token: #{0}",
|
||||
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
|
||||
Detail = [
|
||||
ErrorMessage = tokenExchangeResult[ErrorMessage],
|
||||
ProjectId = parsedUrl[projectId],
|
||||
ServerUrl = parsedUrl[baseUrl]
|
||||
]),
|
||||
|
||||
tokenExchangeResponse = Web.Contents(
|
||||
"http://127.0.0.1:29364/auth/exchange-token",
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json",
|
||||
#"Method" = "POST"
|
||||
],
|
||||
Content = tokenExchangeData,
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500}
|
||||
]
|
||||
),
|
||||
|
||||
tokenExchangeJson = Json.Document(tokenExchangeResponse),
|
||||
weakToken = tokenExchangeJson[token],
|
||||
]
|
||||
],
|
||||
|
||||
// prepare request data with weak token
|
||||
requestData = Json.FromValue([
|
||||
Url = url,
|
||||
Server = parsedUrl[baseUrl],
|
||||
Email = userEmail,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
RootObjectId = modelInfo[rootObjectId],
|
||||
SourceApplication = modelInfo[sourceApplication],
|
||||
Token = weakToken,
|
||||
Version = connectorVersion,
|
||||
VersionId = parsedUrl[versionId],
|
||||
WorkspaceId = workspaceInfo[workspaceId],
|
||||
WorkspaceName = workspaceInfo[workspaceName],
|
||||
WorkspaceLogo = workspaceInfo[workspaceLogo],
|
||||
CanHideBranding = workspaceInfo[canHideBranding]
|
||||
]),
|
||||
|
||||
// Send request to local server
|
||||
Response = Web.Contents(
|
||||
"http://127.0.0.1:29364/download",
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json",
|
||||
#"Method" = "POST"
|
||||
],
|
||||
Content = requestData,
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500}
|
||||
]
|
||||
),
|
||||
|
||||
// Parse response
|
||||
JsonResponse = Json.Document(Response)
|
||||
in
|
||||
JsonResponse,
|
||||
|
||||
// Function to fallback to direct JSON download from Speckle server
|
||||
FallbackToDirectDownload = () =>
|
||||
// downloads data directly from server
|
||||
DirectDownload = (token as text) =>
|
||||
let
|
||||
// Construct the direct object URL: {baseUrl}/objects/{projectId}/{rootObjectId}
|
||||
objectUrl = Text.Combine({
|
||||
parsedUrl[baseUrl],
|
||||
"/objects/",
|
||||
parsedUrl[projectId],
|
||||
"/",
|
||||
parsedUrl[baseUrl],
|
||||
"/objects/",
|
||||
parsedUrl[projectId],
|
||||
"/",
|
||||
modelInfo[rootObjectId]
|
||||
}),
|
||||
|
||||
// Download JSON directly from Speckle server
|
||||
|
||||
Response = Web.Contents(
|
||||
objectUrl,
|
||||
[
|
||||
Headers = [
|
||||
#"Authorization" = "Bearer " & apiKey,
|
||||
#"Authorization" = "Bearer " & token,
|
||||
#"Accept" = "application/json"
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
|
||||
]
|
||||
),
|
||||
|
||||
// Check response status
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
|
||||
// Parse JSON response if successful
|
||||
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
error [
|
||||
Reason = "DirectDownloadFailed",
|
||||
Message = "Failed to download model data directly from Speckle server",
|
||||
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
|
||||
Message.Parameters = {Text.From(StatusCode)},
|
||||
Detail = [
|
||||
StatusCode = StatusCode,
|
||||
StatusCode = StatusCode,
|
||||
ObjectUrl = objectUrl,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
RootObjectId = modelInfo[rootObjectId]
|
||||
@@ -156,13 +93,8 @@
|
||||
in
|
||||
JsonResponse,
|
||||
|
||||
// Check Desktop Service availability and choose approach
|
||||
DesktopServiceAvailable = IsDesktopServiceAvailable(),
|
||||
|
||||
FinalResult = if DesktopServiceAvailable then
|
||||
UseDesktopService()
|
||||
else
|
||||
FallbackToDirectDownload()
|
||||
// download data using the token (weak if exchange succeeded, powerful otherwise)
|
||||
FinalResult = DirectDownload(tokenToUse)
|
||||
|
||||
in
|
||||
FinalResult
|
||||
FinalResult
|
||||
|
||||
@@ -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
+14
-26
@@ -13,10 +13,10 @@
|
||||
"@babel/runtime-corejs3": "^7.21.5",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@speckle/objectloader2": "2.26.2",
|
||||
"@speckle/objectloader2": "2.26.7",
|
||||
"@speckle/tailwind-theme": "2.23.2",
|
||||
"@speckle/ui-components": "2.23.2",
|
||||
"@speckle/viewer": "2.26.4",
|
||||
"@speckle/viewer": "2.26.5",
|
||||
"color-interpolate": "^1.0.5",
|
||||
"core-js": "^3.30.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -3406,21 +3406,21 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@speckle/objectloader2": {
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.26.2.tgz",
|
||||
"integrity": "sha512-sX0Mpi9h54CoWAl58YVCef4JSxWNnB+pFfTjo1XNfBEuyfwL6JBO8j2ho5OYLIyag4VZ1yXu/3MfmSIY4lMq3w==",
|
||||
"version": "2.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.26.7.tgz",
|
||||
"integrity": "sha512-bc3hp/83DBnyp2TOaVtKfqRaWz5UKcWouW281JcFcezTn/TZnB+FSF2csClBhOzrWSqmGVns0KfoJVfflETghQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@speckle/shared": "^2.26.2"
|
||||
"@speckle/shared": "^2.26.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@speckle/shared": {
|
||||
"version": "2.26.4",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.26.4.tgz",
|
||||
"integrity": "sha512-H6iRCpaDVUdUJJyfYwQyLHRL9LlIbS/kjIW+ofOr6HzTON5+U8KizzomWlRKQqDZg9gs6ScJ7EtrsguKeZP6Lg==",
|
||||
"version": "2.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.26.7.tgz",
|
||||
"integrity": "sha512-3TJNmC1JFKvrAfnEDqX7A4P8V/P8M26T8+7sNRtsIMaINFAvKbBjTi7KGFzoAwQmzeXdbUsd2jXF4Kt94W3ZKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
@@ -3545,13 +3545,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@speckle/viewer": {
|
||||
"version": "2.26.4",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/viewer/-/viewer-2.26.4.tgz",
|
||||
"integrity": "sha512-6Cf/5036+UXLqJXMkGfEbyMKZxwCR51xu1dH4lOeEu/c41DG1F6KuSXmoOqEBjemftuEtzUApv/eHVtpWLhXVg==",
|
||||
"version": "2.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/viewer/-/viewer-2.26.5.tgz",
|
||||
"integrity": "sha512-kC/mKYVQZW9f+ek4fcYBEsSEo5fk/cvhBRPFpUzu974gNkaQJAJKWGCziB4U2WHai/twQXJS/yhyrCNOAVBnzQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@speckle/objectloader2": "^2.26.4",
|
||||
"@speckle/shared": "^2.26.4",
|
||||
"@speckle/objectloader2": "^2.26.5",
|
||||
"@speckle/shared": "^2.26.5",
|
||||
"@types/flat": "^5.0.2",
|
||||
"earcut": "3.0.1",
|
||||
"flat": "^5.0.2",
|
||||
@@ -3569,18 +3569,6 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@speckle/viewer/node_modules/@speckle/objectloader2": {
|
||||
"version": "2.26.4",
|
||||
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.26.4.tgz",
|
||||
"integrity": "sha512-CctY8Uk/aN3Iymu0ZpDXeJn6jC04kjuGQzXVxBK+3eeJZEiAEX56hig9IsqzAPOqvgN8yc+zLe2dq3WojivjFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@speckle/shared": "^2.26.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@speckle/viewer/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
"@babel/runtime-corejs3": "^7.21.5",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@speckle/objectloader2": "2.26.2",
|
||||
"@speckle/objectloader2": "2.26.7",
|
||||
"@speckle/tailwind-theme": "2.23.2",
|
||||
"@speckle/ui-components": "2.23.2",
|
||||
"@speckle/viewer": "2.26.4",
|
||||
"@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>
|
||||
|
||||
@@ -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()
|
||||
@@ -166,6 +210,13 @@ 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,83 +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.SHADED]: 'Shaded',
|
||||
[ViewMode.PEN]: 'Pen',
|
||||
[ViewMode.ARCTIC]: 'Arctic'
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -240,7 +311,18 @@ export class ViewerHandler {
|
||||
|
||||
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({
|
||||
@@ -262,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')
|
||||
}
|
||||
@@ -323,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[]>([])
|
||||
|
||||
@@ -425,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)
|
||||
|
||||
@@ -471,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)
|
||||
@@ -555,6 +609,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isLoadingFromFile,
|
||||
cameraPosition,
|
||||
defaultViewModeInFile,
|
||||
sectionBoxData,
|
||||
edgesEnabled,
|
||||
edgesWeight,
|
||||
edgesColor,
|
||||
speckleViews,
|
||||
postFileSaveSkipNeeded,
|
||||
postClickSkipNeeded,
|
||||
@@ -583,6 +641,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
setPostFileSaveSkipNeeded,
|
||||
setCameraPositionInFile,
|
||||
setDefaultViewModeInFile,
|
||||
setEdgesEnabled,
|
||||
setEdgesWeight,
|
||||
setEdgesColor,
|
||||
writeEdgesSettingsToFile,
|
||||
setSpeckleViews,
|
||||
loadObjectsFromFile,
|
||||
setHost,
|
||||
@@ -596,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
|
||||
@@ -165,20 +166,71 @@ export type ReceiveInfo = {
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
async function getReceiveInfo(id) {
|
||||
/**
|
||||
* Extracts userInfoData from encoded string
|
||||
* Returns array of DecodedUserInfo for federated models, single item for single models
|
||||
*/
|
||||
function decodeUserInfoFromId(encodedId: string): DecodedUserInfo[] {
|
||||
try {
|
||||
const ids = (id as string).split(',')
|
||||
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
|
||||
if (!response.body) {
|
||||
console.error('No response body')
|
||||
return { desktopServiceError: true }
|
||||
return decodeUserInfoSafe(encodedId)
|
||||
} catch (error) {
|
||||
console.error('Failed to decode user info from encoded ID:', error)
|
||||
throw new Error(`Invalid encoded user info data: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark version as received
|
||||
async function markVersionAsReceived(
|
||||
versionId: string,
|
||||
projectId: string,
|
||||
serverUrl: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation MarkVersionReceived($input: MarkReceivedVersionInput!) {
|
||||
versionMutations {
|
||||
markReceived(input: $input)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
versionId: versionId,
|
||||
projectId: projectId,
|
||||
sourceApplication: 'powerbi'
|
||||
}
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
const response = await fetch(`${serverUrl}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables: variables
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to mark version as received (status ${response.status}). This is non-critical.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (result.errors) {
|
||||
console.warn('Failed to mark version as received:', result.errors)
|
||||
} else {
|
||||
console.log(`✅ Marked version ${versionId} as received in PowerBI`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log("User info couldn't retrieved from local server.")
|
||||
return { desktopServiceError: true }
|
||||
// Non-critical error - log but don't throw
|
||||
console.warn('Failed to mark version as received:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,18 +238,25 @@ async function fetchFromSpeckleApi(
|
||||
objectIds: string,
|
||||
serverUrl: string,
|
||||
projectId: string,
|
||||
token: string
|
||||
token: string,
|
||||
versionIds?: string[]
|
||||
): Promise<object[][]> {
|
||||
const ids = objectIds.split(',')
|
||||
const modelObjects = []
|
||||
|
||||
for (const objectId of ids) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const objectId = ids[i]
|
||||
try {
|
||||
console.log(`Downloading from Speckle API: ${objectId}`)
|
||||
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
|
||||
const objects = await loader.downloadObjectsWithChildren(objectId)
|
||||
modelObjects.push(objects)
|
||||
console.log(`Downloaded ${objects.length} objects from Speckle`)
|
||||
|
||||
// Mark version as received (non-blocking, best effort)
|
||||
if (versionIds && versionIds[i]) {
|
||||
markVersionAsReceived(versionIds[i], projectId, serverUrl, token)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to download objects from Speckle:`, error)
|
||||
throw error
|
||||
@@ -262,18 +321,27 @@ export async function processMatrixView(
|
||||
|
||||
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
|
||||
// CRITICAL: Validate that internalized data matches current matrix data
|
||||
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
|
||||
if (internalizedRootId !== id) {
|
||||
console.log(
|
||||
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${id}. Using fresh data.`
|
||||
)
|
||||
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
|
||||
} else {
|
||||
console.log(
|
||||
'📁 Successfully validated internalized data matches current matrix:',
|
||||
internalizedModelObjects.length,
|
||||
'models'
|
||||
)
|
||||
// Need to decode id first to get actual root object IDs for comparison
|
||||
try {
|
||||
const decodedForCheck = decodeUserInfoFromId(id)
|
||||
const actualRootIds = decodedForCheck.map((info) => info.rootObjectId).join(',')
|
||||
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
|
||||
|
||||
if (internalizedRootId !== actualRootIds.split(',')[0]) {
|
||||
console.log(
|
||||
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${actualRootIds}. Using fresh data.`
|
||||
)
|
||||
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
|
||||
} else {
|
||||
console.log(
|
||||
'📁 Successfully validated internalized data matches current matrix:',
|
||||
internalizedModelObjects.length,
|
||||
'models'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('📁 Failed to decode ID for internalized data check:', error)
|
||||
internalizedModelObjects = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,17 +363,26 @@ export async function processMatrixView(
|
||||
}
|
||||
|
||||
// Only reload if switching models or not already loaded
|
||||
const needsReload =
|
||||
!visualStore.isViewerObjectsLoaded || visualStore.lastLoadedRootObjectId !== id
|
||||
if (needsReload) {
|
||||
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
|
||||
visualStore.setViewerReloadNeeded()
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
visualStore.setLoadingProgress('📁 Loading from file', null)
|
||||
} else {
|
||||
console.log('📁 Internalized data already loaded, skipping reload')
|
||||
// Need to decode to get actual root object ID for comparison
|
||||
try {
|
||||
const decodedForReload = decodeUserInfoFromId(id)
|
||||
const actualRootIds = decodedForReload.map((info) => info.rootObjectId).join(',')
|
||||
|
||||
const needsReload =
|
||||
!visualStore.isViewerObjectsLoaded ||
|
||||
visualStore.lastLoadedRootObjectId !== actualRootIds
|
||||
if (needsReload) {
|
||||
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
|
||||
visualStore.setViewerReloadNeeded()
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
visualStore.setLoadingProgress('📁 Loading from file', null)
|
||||
} else {
|
||||
console.log('📁 Internalized data already loaded, skipping reload')
|
||||
}
|
||||
visualStore.lastLoadedRootObjectId = actualRootIds // Set to actual root IDs to skip API calls
|
||||
} catch (error) {
|
||||
console.error('📁 Failed to decode ID for reload check:', error)
|
||||
}
|
||||
visualStore.lastLoadedRootObjectId = id // Set to current ID to skip API calls
|
||||
} else {
|
||||
console.error('📁 Failed to unzip internalized data')
|
||||
}
|
||||
@@ -314,59 +391,72 @@ export async function processMatrixView(
|
||||
}
|
||||
}
|
||||
|
||||
// const id = localMatrixView[0].values[0].value as unknown as string
|
||||
console.log('🗝️ Root Object Id: ', id)
|
||||
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
|
||||
// Extract the encoded string from matrix (id is now the base64 encoded userInfo)
|
||||
const encodedId = id
|
||||
console.log('🗝️ Encoded ID: ', encodedId.substring(0, 50) + '...')
|
||||
console.log('Last loaded root object id', visualStore.lastLoadedRootObjectId)
|
||||
|
||||
let modelObjects: object[][] = undefined
|
||||
|
||||
// Decode userInfo first to get actual root object IDs for comparison
|
||||
let decodedUserInfos: DecodedUserInfo[]
|
||||
let actualRootObjectIds: string
|
||||
|
||||
try {
|
||||
decodedUserInfos = decodeUserInfoFromId(encodedId)
|
||||
// Build comma-separated list of actual root object IDs
|
||||
actualRootObjectIds = decodedUserInfos.map((info) => info.rootObjectId).join(',')
|
||||
console.log(`🔓 Decoded ${decodedUserInfos.length} userInfo(s) - Root IDs: ${actualRootObjectIds}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to decode user info:', error)
|
||||
visualStore.setCommonError(
|
||||
'Failed to decode user info from data connector. Please refresh the data.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return {
|
||||
modelObjects: [],
|
||||
objectIds: [],
|
||||
selectedIds: [],
|
||||
colorByIds: null,
|
||||
objectTooltipData: new Map(),
|
||||
isFromStore: false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to reload (compare actual root object IDs, not encoded strings)
|
||||
if (
|
||||
visualStore.lastLoadedRootObjectId !== id &&
|
||||
visualStore.lastLoadedRootObjectId !== actualRootObjectIds &&
|
||||
!visualStore.isLoadingFromFile &&
|
||||
!internalizedModelObjects
|
||||
) {
|
||||
const start = performance.now()
|
||||
|
||||
// Get receive info from desktop service to populate visual store
|
||||
const receiveInfo = await getReceiveInfo(id)
|
||||
let desktopServiceUnavailable = false
|
||||
// Use the first decoded userInfo for visual store (for federated, all have same credentials)
|
||||
const primaryUserInfo = decodedUserInfos[0]
|
||||
|
||||
if (receiveInfo && !receiveInfo.desktopServiceError) {
|
||||
visualStore.setReceiveInfo({
|
||||
userEmail: receiveInfo.email || receiveInfo.Email,
|
||||
serverUrl: receiveInfo.server || receiveInfo.Server,
|
||||
sourceApplication: getSlugFromHostAppNameAndVersion(
|
||||
receiveInfo.sourceApplication || receiveInfo.SourceApplication
|
||||
),
|
||||
workspaceId: receiveInfo.workspaceId || receiveInfo.WorkspaceId,
|
||||
workspaceName: receiveInfo.workspaceName || receiveInfo.WorkspaceName,
|
||||
workspaceLogo: receiveInfo.workspaceLogo || receiveInfo.WorkspaceLogo,
|
||||
version: receiveInfo.version || receiveInfo.Version,
|
||||
canHideBranding: receiveInfo.canHideBranding ?? receiveInfo.CanHideBranding,
|
||||
token: receiveInfo.weakToken || receiveInfo.WeakToken,
|
||||
projectId: receiveInfo.projectId || receiveInfo.ProjectId
|
||||
})
|
||||
console.log(`Receive info retrieved from desktop service - credentials loaded`)
|
||||
} else {
|
||||
desktopServiceUnavailable = true
|
||||
console.log('Desktop service unavailable - cannot retrieve credentials')
|
||||
}
|
||||
visualStore.setReceiveInfo({
|
||||
userEmail: primaryUserInfo.email,
|
||||
serverUrl: primaryUserInfo.server,
|
||||
sourceApplication: getSlugFromHostAppNameAndVersion(primaryUserInfo.sourceApplication || ''),
|
||||
workspaceId: primaryUserInfo.workspaceId || undefined,
|
||||
workspaceName: primaryUserInfo.workspaceName || undefined,
|
||||
workspaceLogo: primaryUserInfo.workspaceLogo || undefined,
|
||||
version: primaryUserInfo.version,
|
||||
canHideBranding: primaryUserInfo.canHideBranding || false,
|
||||
token: primaryUserInfo.token,
|
||||
projectId: primaryUserInfo.projectId
|
||||
})
|
||||
console.log(`✅ Credentials loaded from encoded data`)
|
||||
|
||||
// Now get the data from visual store for Speckle API download
|
||||
const token = visualStore.receiveInfo?.token
|
||||
const serverUrl = visualStore.receiveInfo?.serverUrl
|
||||
const projectId = visualStore.receiveInfo?.projectId
|
||||
// Get credentials for Speckle API download
|
||||
const token = primaryUserInfo.token
|
||||
const serverUrl = primaryUserInfo.server
|
||||
const projectId = primaryUserInfo.projectId
|
||||
|
||||
if (!token || !serverUrl || !projectId) {
|
||||
if (desktopServiceUnavailable) {
|
||||
visualStore.setCommonError(
|
||||
'Speckle Desktop Service is not running. Please start Speckle Desktop Services and refresh data.'
|
||||
)
|
||||
} else {
|
||||
visualStore.setCommonError(
|
||||
'Missing Speckle credentials. Please refresh the data from the data connector.'
|
||||
)
|
||||
}
|
||||
visualStore.setCommonError(
|
||||
'Missing required credentials in encoded data. Please refresh the data from the data connector.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return {
|
||||
modelObjects: [],
|
||||
@@ -381,9 +471,18 @@ export async function processMatrixView(
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
|
||||
console.log('Downloading objects directly from Speckle API...')
|
||||
console.log(`Server: ${serverUrl}, Project: ${projectId}, Object: ${id}`)
|
||||
console.log(`Server: ${serverUrl}, Project: ${projectId}, Objects: ${actualRootObjectIds}`)
|
||||
try {
|
||||
modelObjects = await fetchFromSpeckleApi(id, serverUrl, projectId, token)
|
||||
// Extract versionIds for markAsReceived
|
||||
const versionIds = decodedUserInfos.map((info) => info.versionId).filter(Boolean) as string[]
|
||||
|
||||
modelObjects = await fetchFromSpeckleApi(
|
||||
actualRootObjectIds,
|
||||
serverUrl,
|
||||
projectId,
|
||||
token,
|
||||
versionIds.length > 0 ? versionIds : undefined
|
||||
)
|
||||
console.log('Successfully downloaded from Speckle API')
|
||||
|
||||
// Debug: Check what we're passing to the viewer
|
||||
|
||||
@@ -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