Compare commits

...

12 Commits

Author SHA1 Message Date
Dogukan Karatas a9fd34831c feat: move from embed token api to share token api (#230)
* new mutation for token exchange

* variable based
2026-04-15 14:09:12 +03:00
Dogukan Karatas dbac5c013b fix (auth): new auth endpoint (#229)
* new endpoint for auth

* fallback added

* remove appSecret
2026-04-02 16:54:20 +03:00
Mucahit Bilal GOKER a73e832816 feat(visual): add sectioning tool (#226)
* section box tool

* save section box

* force section outlines recomputation

* extract duplicate section box logic

* vector3 simplification

* extract section enable helper

* fix misleading type casts

* fix let const usage

* add section box error handling

* fix section box state sync

* capabilities

* replace section box bool pair with state enum

* change scissors icon behaviour

* add clarifying comment for toggleSectionBox
2026-02-20 12:55:45 +03:00
Mucahit Bilal GOKER 0b55013a84 feat: consolidate camera controls (#225) 2026-02-12 23:39:37 +03:00
Dogukan Karatas baa723287b Merge pull request #224 from specklesystems/dogukan/cnx-3009-data-connector
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
feat(data): add markReceived mutation to data connector
2026-01-30 08:53:27 +01:00
Dogukan Karatas 0976597db3 mark received data connector 2026-01-29 12:38:15 +01:00
Mucahit Bilal GOKER 40536a565f feat: add Application ID column (#223)
* add application id column

* use record
2026-01-26 12:06:13 +03:00
Dogukan Karatas 34115d9a5d feat (visual): align visual with FE2 (#222)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
* view mode menu updated

* clean up
2026-01-19 23:19:51 +03:00
Mucahit Bilal GOKER 74ac3e3990 add object and application ids (#221) 2026-01-15 20:40:46 +03:00
Mucahit Bilal GOKER f9b5e250d8 feat: Issues helper function (#194)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
* helper func

* register

* query: remove code duplicate

* discussions gone, issues in.

* add issue urls

* add optional replies input
2026-01-06 16:02:53 +03:00
Dogukan Karatas 0befca0200 Merge pull request #220 from specklesystems/dogukan/delimeter-fix
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix (data): federate helper function delimeter fix
2025-12-12 15:59:29 +01:00
Dogukan Karatas 1a74336e27 fixed delimeter 2025-12-12 15:57:05 +01:00
21 changed files with 1137 additions and 466 deletions
+50 -23
View File
@@ -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
[
@@ -14,6 +14,7 @@
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) =>
@@ -65,7 +66,8 @@
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
parsedUrl[baseUrl],
parsedUrl[resourceIdString]
),
// throw error if token exchange failed - do NOT use powerful token as fallback
@@ -146,6 +148,9 @@
sourceApplication = modelInfo[sourceApplication],
versionId = modelInfo[versionId],
// mark version as received
markReceivedResult = MarkReceived(powerfulToken, versionId, parsedUrl[projectId], parsedUrl[baseUrl]),
// get structured data
structuredData = GetStructuredData(url),
@@ -178,7 +183,7 @@
structuredData,
{
{"Version Object ID", each rootObjectId},
{"Model Info", each if desktopServiceSent or not desktopServiceSent then encodedUserInfo else encodedUserInfo}
{"Model Info", each if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) then encodedUserInfo else encodedUserInfo}
}
),
@@ -240,6 +245,10 @@
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),
@@ -269,13 +278,13 @@
encodedUserInfo = EncodeUserInfo(userInfoData),
// add the model name as context - with version id if exists
// reference desktopServiceSent to force evaluation
// reference desktopServiceSent and markReceivedResult to force evaluation
result = Table.AddColumn(
structuredData,
"Source Model",
each if versionId <> null then
Text.Combine({modelName, "-", versionId})
else if desktopServiceSent or not desktopServiceSent then
if (markReceivedResult or not markReceivedResult) then Text.Combine({modelName, "-", versionId}) else Text.Combine({modelName, "-", versionId})
else if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) then
modelName
else
modelName,
@@ -1,9 +1,6 @@
// Function to exchange powerful token for weak limited token
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text) as record =>
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text, optional resourceIdString as text) as record =>
let
// Import the parser function for URL handling
Parser = Extension.LoadFunction("Parser.pqm"),
// Helper function to load .pqm modules dynamically
Extension.LoadFunction = (fileName as text) =>
let
@@ -33,114 +30,106 @@
else
null,
// Token lifetime: 10 years (315,360,000,000 milliseconds)
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
// Generate token name with timestamp
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
// Build scopes array string for GraphQL (e.g., ["profile:read", "streams:read"])
ScopesArray = Text.Combine(
List.Transform(scopes, each """" & _ & """"),
", "
),
// Build GraphQL mutation
GraphQLMutation = "
mutation {
apiTokenCreate(token: {
name: """ & TokenName & """,
scopes: [" & ScopesArray & "],
lifespan: " & Number.ToText(TokenLifespanMs) & ",
limitResources: [{
type: project,
id: """ & projectId & """
}]
})
}",
// Execute token exchange if validation passes
Result = if ValidationError <> null then
[
Success = false,
Token = null,
ErrorMessage = ValidationError
]
// Ensure serverUrl ends with /
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
serverUrl
else
try
let
// Ensure serverUrl ends with /
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
serverUrl
else
serverUrl & "/",
serverUrl & "/",
// Make GraphQL request
Response = Web.Contents(
NormalizedServerUrl & "graphql",
[
Headers = [
#"Method" = "POST",
#"Content-Type" = "application/json",
#"Authorization" = "Bearer " & powerfulToken
],
Content = Json.FromValue([
query = GraphQLMutation
]),
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 10)
]
),
// New Share Token API mutation with variables
NewGraphQLQuery = "mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
sharingMutations {
createEmbedShareToken(input: $input) {
token
}
}
}",
NewGraphQLVariables = [
input = [
projectId = projectId,
resourceIdString = resourceIdString
]
],
StatusCode = Value.Metadata(Response)[Response.Status],
// 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
]}
]
],
// Parse response if successful
ParsedResult = if StatusCode >= 200 and StatusCode < 300 then
let
JsonResponse = Json.Document(Response),
// 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],
// Check for GraphQL errors
HasErrors = Record.HasFields(JsonResponse, {"errors"}),
// 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],
// Extract token from response
WeakToken = if not HasErrors then
JsonResponse[data][apiTokenCreate]
else
null,
ErrorMsg = if HasErrors then
try
JsonResponse[errors]{0}[message]
otherwise
"GraphQL mutation failed with unknown error"
else
null
in
if WeakToken <> null then
[
Success = true,
Token = WeakToken,
ErrorMessage = null
]
else
[
Success = false,
Token = null,
ErrorMessage = ErrorMsg
]
else
[
Success = false,
Token = null,
ErrorMessage = "GraphQL request failed with status " & Number.ToText(StatusCode)
]
in
ParsedResult
otherwise
[
Success = false,
Token = null,
ErrorMessage = "Token exchange request failed with exception"
]
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
@@ -57,6 +57,7 @@
#"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
@@ -19,7 +19,7 @@ let
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
DistinctModelInfo = List.Distinct(CombinedTable[Model Info]),
ConcatenatedModelInfo = Text.Combine(DistinctModelInfo, ","),
ConcatenatedModelInfo = Text.Combine(DistinctModelInfo, "|||"),
// Replace all Version Object ID values with the concatenated string
TableWithVersionObjectID = Table.ReplaceValue(
@@ -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
@@ -33,7 +33,8 @@
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
parsedUrl[baseUrl],
parsedUrl[resourceIdString]
),
// throw error if token exchange failed - do NOT use powerful token as fallback
+16
View File
@@ -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": {
@@ -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>
@@ -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>
+97 -11
View File
@@ -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,
+45 -35
View File
@@ -149,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) {