Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34115d9a5d | |||
| 74ac3e3990 | |||
| f9b5e250d8 |
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -92,6 +92,15 @@
|
||||
},
|
||||
"navbarHidden": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesEnabled": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesWeight": {
|
||||
"type": { "numeric": true }
|
||||
},
|
||||
"edgesColor": {
|
||||
"type": { "numeric": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,13 +33,22 @@
|
||||
</ViewerControlsButtonToggle>
|
||||
</ViewerControlsButtonGroup>
|
||||
<ViewerControlsButtonGroup>
|
||||
<!-- View Modes -->
|
||||
<ViewerViewModesMenu
|
||||
:open="viewModesOpen"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'viewModes' : 'none')"
|
||||
@view-mode-clicked="(value) => $emit('view-mode-clicked', value)"
|
||||
/>
|
||||
<!-- View Modes Toggle -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="View modes"
|
||||
:active="viewModesOpen"
|
||||
@click="toggleActiveControl('viewModes')"
|
||||
>
|
||||
<ViewModesIcon class="h-5 w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<!-- View Modes Panel (shown when glasses icon is clicked) -->
|
||||
<ViewerViewModesMenu
|
||||
v-if="viewModesOpen"
|
||||
@view-mode-clicked="(viewMode, options) => $emit('view-mode-clicked', viewMode, options)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Views -->
|
||||
<ViewerViewsMenu
|
||||
:open="viewsOpen"
|
||||
@@ -65,7 +74,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowsPointingOutIcon } from '@heroicons/vue/24/solid'
|
||||
import { SpeckleView } from '@speckle/viewer'
|
||||
import { SpeckleView, ViewMode } from '@speckle/viewer'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerControlsButtonGroup from './viewer/controls/ViewerControlsButtonGroup.vue'
|
||||
@@ -76,19 +85,21 @@ import ViewerViewsMenu from './viewer/views/ViewerViewsMenu.vue'
|
||||
|
||||
import Perspective from '../components/global/icon/Perspective.vue'
|
||||
import PerspectiveMore from '../components/global/icon/PerspectiveMore.vue'
|
||||
import ViewModesIcon from '../components/global/icon/ViewModes.vue'
|
||||
|
||||
import Ghost from '../components/global/icon/Ghost.vue'
|
||||
import ZoomToFit from '../components/global/icon/ZoomToFit.vue'
|
||||
import type { ViewModeOptions } from '@src/plugins/viewer'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
const emits = defineEmits([
|
||||
'update:sectionBox',
|
||||
'view-clicked',
|
||||
'toggle-projection',
|
||||
'clear-palette',
|
||||
'view-mode-clicked'
|
||||
])
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:sectionBox', value: boolean): void
|
||||
(e: 'view-clicked', view: SpeckleView): void
|
||||
(e: 'toggle-projection'): void
|
||||
(e: 'clear-palette'): void
|
||||
(e: 'view-mode-clicked', viewMode: ViewMode, options: ViewModeOptions): void
|
||||
}>()
|
||||
withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
sectionBox: false
|
||||
})
|
||||
@@ -130,12 +141,7 @@ const toggleZoomOnFilter = () => {
|
||||
visualStore.writeZoomOnFilterToFile()
|
||||
}
|
||||
|
||||
const viewModesOpen = computed({
|
||||
get: () => activeControl.value === 'viewModes',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'viewModes' : 'none'
|
||||
}
|
||||
})
|
||||
const viewModesOpen = computed(() => activeControl.value === 'viewModes')
|
||||
|
||||
const viewsOpen = computed({
|
||||
get: () => activeControl.value === 'views',
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
:views="views"
|
||||
class="fixed top-11 left-2 z-30"
|
||||
@view-clicked="(view) => viewerHandler.setView(view)"
|
||||
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
|
||||
@view-mode-clicked="(viewMode, options) => viewerHandler.setViewMode(viewMode, options)"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -35,12 +35,19 @@ export interface Hit {
|
||||
point: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
export interface ViewModeOptions {
|
||||
edges?: boolean
|
||||
outlineThickness?: number
|
||||
outlineOpacity?: number
|
||||
outlineColor?: number
|
||||
}
|
||||
|
||||
export interface IViewerEvents {
|
||||
ping: (message: string) => void
|
||||
setSelection: (objectIds: string[]) => void
|
||||
resetFilter: (objectIds: string[], ghost: boolean, zoom: boolean) => void
|
||||
filterSelection: (objectIds: string[], ghost: boolean, zoom: boolean) => void
|
||||
setViewMode: (viewMode: ViewMode) => void
|
||||
setViewMode: (viewMode: ViewMode, options?: ViewModeOptions) => void
|
||||
colorObjectsByGroup: (
|
||||
colorById: {
|
||||
objectIds: string[]
|
||||
@@ -138,9 +145,9 @@ export class ViewerHandler {
|
||||
return
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode) {
|
||||
public setViewMode(viewMode: ViewMode, options?: ViewModeOptions) {
|
||||
const viewModes = this.viewer.getExtension(ViewModes)
|
||||
viewModes.setViewMode(viewMode)
|
||||
viewModes.setViewMode(viewMode, options)
|
||||
}
|
||||
|
||||
public snapshotCameraPositionAndStore = () => {
|
||||
@@ -240,7 +247,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({
|
||||
|
||||
@@ -62,6 +62,11 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
const cameraPosition = ref<number[]>(undefined)
|
||||
const defaultViewModeInFile = ref<string>(undefined)
|
||||
|
||||
// Edges settings for view modes
|
||||
const edgesEnabled = ref<boolean>(true)
|
||||
const edgesWeight = ref<number>(1)
|
||||
const edgesColor = ref<number | 'auto'>('auto')
|
||||
|
||||
const speckleViews = ref<SpeckleView[]>([])
|
||||
|
||||
// callback mechanism to viewer to be able to manage input data accordingly.
|
||||
@@ -471,6 +476,37 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
const setCameraPositionInFile = (newValue: number[]) => (cameraPosition.value = newValue)
|
||||
const setDefaultViewModeInFile = (newValue: string) => (defaultViewModeInFile.value = newValue)
|
||||
|
||||
// Edges settings setters
|
||||
const setEdgesEnabled = (val: boolean) => {
|
||||
edgesEnabled.value = val
|
||||
}
|
||||
|
||||
const setEdgesWeight = (val: number) => {
|
||||
edgesWeight.value = val
|
||||
}
|
||||
|
||||
const setEdgesColor = (val: number | 'auto') => {
|
||||
edgesColor.value = val
|
||||
}
|
||||
|
||||
const writeEdgesSettingsToFile = () => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unnecessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'viewMode',
|
||||
properties: {
|
||||
edgesEnabled: edgesEnabled.value,
|
||||
edgesWeight: edgesWeight.value,
|
||||
edgesColor: edgesColor.value === 'auto' ? -1 : edgesColor.value
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const setSpeckleViews = (newSpeckleViews: SpeckleView[]) => (speckleViews.value = newSpeckleViews)
|
||||
const setFormattingSettings = (newFormattingSettings: SpeckleVisualSettingsModel) =>
|
||||
(formattingSettings.value = newFormattingSettings)
|
||||
@@ -555,6 +591,9 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isLoadingFromFile,
|
||||
cameraPosition,
|
||||
defaultViewModeInFile,
|
||||
edgesEnabled,
|
||||
edgesWeight,
|
||||
edgesColor,
|
||||
speckleViews,
|
||||
postFileSaveSkipNeeded,
|
||||
postClickSkipNeeded,
|
||||
@@ -583,6 +622,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
setPostFileSaveSkipNeeded,
|
||||
setCameraPositionInFile,
|
||||
setDefaultViewModeInFile,
|
||||
setEdgesEnabled,
|
||||
setEdgesWeight,
|
||||
setEdgesColor,
|
||||
writeEdgesSettingsToFile,
|
||||
setSpeckleViews,
|
||||
loadObjectsFromFile,
|
||||
setHost,
|
||||
|
||||
@@ -185,6 +185,24 @@ export class Visual implements IVisual {
|
||||
)
|
||||
}
|
||||
|
||||
// Load edges settings
|
||||
const viewModeSettings = options.dataViews[0].metadata.objects.viewMode
|
||||
if (viewModeSettings) {
|
||||
if ('edgesEnabled' in viewModeSettings) {
|
||||
console.log(`Edges Enabled: ${viewModeSettings.edgesEnabled as boolean}`)
|
||||
visualStore.setEdgesEnabled(viewModeSettings.edgesEnabled as boolean)
|
||||
}
|
||||
if ('edgesWeight' in viewModeSettings) {
|
||||
console.log(`Edges Weight: ${viewModeSettings.edgesWeight as number}`)
|
||||
visualStore.setEdgesWeight(viewModeSettings.edgesWeight as number)
|
||||
}
|
||||
if ('edgesColor' in viewModeSettings) {
|
||||
const colorVal = viewModeSettings.edgesColor as number
|
||||
console.log(`Edges Color: ${colorVal}`)
|
||||
visualStore.setEdgesColor(colorVal === -1 ? 'auto' : colorVal)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
|
||||
console.log(`Stored camera position is found`)
|
||||
visualStore.setCameraPositionInFile([
|
||||
|
||||
Reference in New Issue
Block a user