Merge branch 'main' of github.com:specklesystems/speckle-server into gergo/web-2124-set-up-email-notifications-for-trial-expiration

This commit is contained in:
Gergő Jedlicska
2024-12-13 17:52:06 +01:00
61 changed files with 1727 additions and 896 deletions
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.5" />
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.7" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
@@ -1,5 +1,9 @@
<template>
<CommonBadge :color-classes="[runStatusClasses(run), 'shrink-0 grow-0'].join(' ')">
<CommonBadge
:color-classes="
[runStatusClasses(run), 'shrink-0 grow-0 text-foreground'].join(' ')
"
>
{{ run.status.toUpperCase() }}
</CommonBadge>
</template>
@@ -7,7 +7,7 @@
:disabled-item-tooltip="
!allowGuest ? 'The Guest role isn\'t enabled on the server' : ''
"
name="serverRoles"
:name="name ?? 'serverRoles'"
label="Role"
:show-label="showLabel"
class="min-w-[110px]"
@@ -75,7 +75,8 @@ const props = defineProps({
allowAdmin: Boolean,
allowArchived: Boolean,
fullyControlValue: Boolean,
showLabel: Boolean
showLabel: Boolean,
name: String
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
@@ -0,0 +1,45 @@
<template>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 7H6L3 15V17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M16 7H18L21 15V17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10 16H14"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 16.5C14 17.4283 14.3687 18.3185 15.0251 18.9749C15.6815 19.6313 16.5717 20 17.5 20C18.4283 20 19.3185 19.6313 19.9749 18.9749C20.6313 18.3185 21 17.4283 21 16.5C21 15.5717 20.6313 14.6815 19.9749 14.0251C19.3185 13.3687 18.4283 13 17.5 13C16.5717 13 15.6815 13.3687 15.0251 14.0251C14.3687 14.6815 14 15.5717 14 16.5Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 16.5C3 16.9596 3.09053 17.4148 3.26642 17.8394C3.44231 18.264 3.70012 18.6499 4.02513 18.9749C4.35013 19.2999 4.73597 19.5577 5.16061 19.7336C5.58525 19.9095 6.04037 20 6.5 20C6.95963 20 7.41475 19.9095 7.83939 19.7336C8.26403 19.5577 8.64987 19.2999 8.97487 18.9749C9.29988 18.6499 9.55769 18.264 9.73358 17.8394C9.90947 17.4148 10 16.9596 10 16.5C10 16.0404 9.90947 15.5852 9.73358 15.1606C9.55769 14.736 9.29988 14.3501 8.97487 14.0251C8.64987 13.7001 8.26403 13.4423 7.83939 13.2664C7.41475 13.0905 6.95963 13 6.5 13C6.04037 13 5.58525 13.0905 5.16061 13.2664C4.73597 13.4423 4.35013 13.7001 4.02513 14.0251C3.70012 14.3501 3.44231 14.736 3.26642 15.1606C3.09053 15.5852 3 16.0404 3 16.5Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -124,7 +124,7 @@
</MenuItems>
</Transition>
</Menu>
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
<InviteDialogServer v-model:open="showInviteDialog" />
<SettingsDialog
v-model:open="showSettingsDialog"
v-model:target-menu-item="settingsDialogTarget"
@@ -0,0 +1,176 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
<template #header>Invite to Speckle</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-y-5 text-foreground">
<div v-for="(item, index) in fields" :key="item.key" class="flex gap-x-3">
<div class="flex flex-col gap-y-3 flex-1">
<hr v-if="index !== 0" class="border-outline-3" />
<div class="flex flex-row gap-x-3 items-center">
<div class="flex-1">
<FormTextInput
v-model="item.value.email"
:name="`email-${item.key}`"
color="foundation"
placeholder="Email address"
show-clear
full-width
use-label-in-errors
show-label
label="Email"
:rules="[isEmail]"
/>
</div>
<FormSelectServerRoles
v-if="allowServerRoleSelect"
v-model="item.value.serverRole"
label="Select role"
:name="`role-${item.key}`"
class="sm:w-48"
show-label
:disabled="anyMutationsLoading"
:allow-guest="isGuestMode"
:allow-admin="isAdmin"
mount-menu-on-body
/>
</div>
<FormSelectProjects
v-model="item.value.project"
label="Select project"
class="w-full"
owned-only
show-optional
mount-menu-on-body
show-label
:name="`project-${index}`"
/>
</div>
<div class="relative w-4">
<CommonTextLink
v-if="fields.length > 1"
class="top-10 absolute right-0"
:class="{ 'top-7': index === 0 }"
@click="removeInviteItem(index)"
>
<TrashIcon class="h-4 w-4 text-foreground-2" />
</CommonTextLink>
</div>
</div>
<FormButton
color="subtle"
:icon-left="PlusIcon"
:disabled="anyMutationsLoading"
@click="addInviteItem"
>
Invite another user
</FormButton>
</div>
</form>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useMutationLoading } from '@vue/apollo-composable'
import { useForm, useFieldArray } from 'vee-validate'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useServerInfo } from '~~/lib/core/composables/server'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useInviteUserToServer } from '~~/lib/server/composables/invites'
import { PlusIcon, TrashIcon } from '@heroicons/vue/24/outline'
import type { InviteServerForm, InviteServerItem } from '~~/lib/invites/helpers/types'
import { emptyInviteServerItem } from '~~/lib/invites/helpers/constants'
import { isEmail } from '~~/lib/common/helpers/validation'
const isOpen = defineModel<boolean>('open', { required: true })
const { handleSubmit } = useForm<InviteServerForm>({
initialValues: {
fields: [
{
...emptyInviteServerItem
}
]
}
})
const {
fields,
replace: replaceFields,
push: pushInvite,
remove: removeInvite
} = useFieldArray<InviteServerItem>('fields')
const { mutate: inviteUserToServer } = useInviteUserToServer()
const inviteUserToProject = useInviteUserToProject()
const anyMutationsLoading = useMutationLoading()
const { isAdmin } = useActiveUser()
const { isGuestMode } = useServerInfo()
const mixpanel = useMixpanel()
const allowServerRoleSelect = computed(() => isAdmin.value || isGuestMode.value)
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => {
isOpen.value = false
}
},
{
text: 'Invite',
props: {
submit: true,
disabled: anyMutationsLoading.value
},
onClick: onSubmit
}
])
const addInviteItem = () => {
pushInvite({ ...emptyInviteServerItem })
}
const removeInviteItem = (index: number) => {
removeInvite(index)
}
const onSubmit = handleSubmit(() => {
const invites = fields.value.filter((invite) => invite.value.email)
invites.forEach(async (invite) => {
invite.value.project
? await inviteUserToProject(invite.value.project.id, [
{
email: invite.value.email,
serverRole: invite.value.serverRole
}
])
: await inviteUserToServer([
{
email: invite.value.email,
serverRole: invite.value.serverRole
}
])
})
mixpanel.track('Invite Action', {
type: 'server invite',
name: 'send',
multiple: fields.value.length !== 1,
count: fields.value.length,
hasProject: !!fields.value.some((invite) => invite.value.project),
to: 'email'
})
isOpen.value = false
})
watch(isOpen, (newVal, oldVal) => {
if (newVal && !oldVal) {
replaceFields([
{
...emptyInviteServerItem
}
])
}
})
</script>
@@ -213,7 +213,7 @@
>
<template #header>Your first upload</template>
</OnboardingDialogFirstSend>
<SettingsServerUserInviteDialog
<InviteDialogServer
v-model:open="showServerInviteDialog"
@update:open="(v) => (!v ? markComplete(3) : '')"
/>
@@ -39,9 +39,6 @@ graphql(`
workspace {
slug
}
automations(limit: 0) {
totalCount
}
}
`)
@@ -7,10 +7,6 @@
<strong>delete {{ project.name }}</strong>
and all its contents, including
<strong>{{ project.models.totalCount }} {{ modelText }}</strong>
<span v-if="project.automations.totalCount">
and
<strong>{{ project.automations.totalCount }} {{ automationText }}</strong>
</span>
<span v-if="project.commentThreads.totalCount">
and
<strong>{{ project.commentThreads.totalCount }} {{ discussionText }}</strong>
@@ -61,9 +57,6 @@ const modelText = computed(() =>
const discussionText = computed(() =>
props.project.commentThreads.totalCount === 1 ? 'discussion' : 'discussions'
)
const automationText = computed(() =>
props.project.automations.totalCount === 1 ? 'automation' : 'automations'
)
const dialogButtons = computed<LayoutDialogButton[]>(() => [
{
@@ -98,7 +98,7 @@
:user="userToModify"
/>
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
<InviteDialogServer v-model:open="showInviteDialog" />
</div>
</template>
@@ -70,7 +70,7 @@
@infinite="onInfiniteLoad"
/>
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
<InviteDialogServer v-model:open="showInviteDialog" />
</div>
</template>
@@ -1,136 +0,0 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
<template #header>Get your colleagues in!</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-y-3 text-foreground mb-4">
<p class="text-body-xs">
Speckle will send a server invite link to the email(-s) below. You can also
add a personal message if you want to. To add multiple e-mails, seperate them
with commas.
</p>
<FormTextInput
name="emailsString"
color="foundation"
label="E-mail"
show-label
placeholder="example@example.com, example2@example.com"
:rules="[isRequired, isOneOrMultipleEmails]"
:disabled="anyMutationsLoading"
/>
<FormSelectServerRoles
v-if="allowServerRoleSelect"
v-model="serverRole"
label="Select server role"
class="w-full sm:w-60"
show-label
:allow-guest="isGuestMode"
:allow-admin="isAdmin"
mount-menu-on-body
/>
<FormTextArea
name="message"
show-optional
show-label
label="Message"
color="foundation"
:disabled="anyMutationsLoading"
:rules="[isStringOfLength({ maxLength: 1024 })]"
placeholder="Write an optional invitation message!"
/>
<FormSelectProjects
v-model="selectedProject"
label="Select project to invite to"
class="w-full sm:w-60"
owned-only
show-optional
mount-menu-on-body
show-label
/>
</div>
</form>
</LayoutDialog>
</template>
<script setup lang="ts">
import { Roles } from '@speckle/shared'
import type { Optional, ServerRoles } from '@speckle/shared'
import type { LayoutDialogButton } from '@speckle/ui-components'
import { useMutationLoading } from '@vue/apollo-composable'
import { useForm } from 'vee-validate'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
import {
isRequired,
isOneOrMultipleEmails,
isStringOfLength
} from '~~/lib/common/helpers/validation'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useServerInfo } from '~~/lib/core/composables/server'
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
import { useInviteUserToServer } from '~~/lib/server/composables/invites'
const selectedProject = ref(undefined as Optional<FormSelectProjects_ProjectFragment>)
const serverRole = ref<ServerRoles>(Roles.Server.User)
const { handleSubmit } = useForm<{ message?: string; emailsString: string }>()
const { mutate: inviteUserToServer } = useInviteUserToServer()
const inviteUserToProject = useInviteUserToProject()
const anyMutationsLoading = useMutationLoading()
const { isAdmin } = useActiveUser()
const { isGuestMode } = useServerInfo()
const isOpen = defineModel<boolean>('open', { required: true })
const allowServerRoleSelect = computed(() => isAdmin.value || isGuestMode.value)
const mp = useMixpanel()
const onSubmit = handleSubmit(async (values) => {
const emails = values.emailsString.split(',').map((i) => i.trim())
const project = selectedProject.value
const success = project
? await inviteUserToProject(
project.id,
emails.map((email) => ({
email,
serverRole: allowServerRoleSelect.value ? serverRole.value : undefined
}))
)
: await inviteUserToServer(
emails.map((email) => ({
email,
message: values.message,
serverRole: allowServerRoleSelect.value ? serverRole.value : undefined
}))
)
if (success) {
isOpen.value = false
selectedProject.value = undefined
mp.track('Invite Action', {
type: 'server invite',
name: 'send',
multiple: emails.length !== 1,
count: emails.length,
hasProject: !!project,
to: 'email'
})
}
})
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'outline' },
onClick: () => {
isOpen.value = false
}
},
{
text: 'Send',
props: {
submit: true,
disabled: anyMutationsLoading.value
},
onClick: onSubmit
}
])
</script>
@@ -49,30 +49,38 @@
</p>
</div>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-1">
{{
statusIsTrial
? 'Expected bill'
: subscription?.billingInterval === BillingInterval.Yearly
? 'Annual bill'
: 'Monthly bill'
}}
</h3>
<p class="text-heading-lg text-foreground inline-block">
{{ billValue }} per
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'year'
: 'month'
}}
</p>
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
{{ billDescription }}
<InformationCircleIcon
v-tippy="billTooltip"
class="w-4 h-4 text-foreground cursor-pointer"
/>
</p>
<template v-if="isPurchasablePlan || statusIsTrial">
<h3 class="text-body-xs text-foreground-2 pb-1">
{{
statusIsTrial
? 'Expected bill'
: subscription?.billingInterval === BillingInterval.Yearly
? 'Annual bill'
: 'Monthly bill'
}}
</h3>
<p class="text-heading-lg text-foreground inline-block">
{{ billValue }} per
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'year'
: 'month'
}}
</p>
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
{{ billDescription }}
<InformationCircleIcon
v-tippy="billTooltip"
class="w-4 h-4 text-foreground cursor-pointer"
/>
</p>
</template>
<template v-else>
<h3 class="text-body-xs text-foreground-2 pb-1">Expected bill</h3>
<p class="text-heading-lg text-foreground inline-block">
{{ isAcademiaPlan ? 'Free' : 'Not applicable' }}
</p>
</template>
</div>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-1">
@@ -123,6 +131,7 @@
class="pt-4"
/>
<SettingsWorkspacesBillingPricingTable
v-if="isPurchasablePlan || statusIsTrial"
:workspace-id="workspaceId"
:current-plan="currentPlan"
:active-billing-interval="subscription?.billingInterval"
@@ -262,6 +271,9 @@ const isActivePlan = computed(
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled
)
const isAcademiaPlan = computed(
() => currentPlan.value?.name === WorkspacePlans.Academia
)
const isPurchasablePlan = computed(() => isPaidPlan(currentPlan.value?.name))
const seatPrice = computed(() =>
currentPlan.value && subscription.value
@@ -1,6 +1,6 @@
<template>
<Teleport to="#toast-portal">
<GlobalToastRenderer v-model:notification="notification" />
<GlobalToastRenderer v-model:notifications="notifications" @dismiss="dismiss" />
</Teleport>
</template>
@@ -8,13 +8,13 @@
import { useGlobalToastManager } from '~~/lib/common/composables/toast'
import { GlobalToastRenderer } from '@speckle/ui-components'
const { currentNotification, dismiss } = useGlobalToastManager()
const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager()
const notification = computed({
get: () => currentNotification.value,
const notifications = computed({
get: () => currentNotifications.value,
set: (newVal) => {
if (!newVal) {
dismiss()
dismissAll()
}
}
})
+143 -162
View File
@@ -13,27 +13,27 @@
>
<!-- Models -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : modelsShortcut"
:active="activeControl === 'models'"
@click="toggleActiveControl('models')"
v-tippy="getShortcutDisplayText(shortcuts.ToggleModels)"
:active="activePanel === 'models'"
@click="toggleActivePanel('models')"
>
<CubeIcon class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Explorer -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : explorerShortcut"
:active="activeControl === 'explorer'"
@click="toggleActiveControl('explorer')"
v-tippy="getShortcutDisplayText(shortcuts.ToggleExplorer)"
:active="activePanel === 'explorer'"
@click="toggleActivePanel('explorer')"
>
<IconFileExplorer class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Comment threads -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : discussionsShortcut"
:active="activeControl === 'discussions'"
@click="toggleActiveControl('discussions')"
v-tippy="getShortcutDisplayText(shortcuts.ToggleDiscussions)"
:active="activePanel === 'discussions'"
@click="toggleActivePanel('discussions')"
>
<ChatBubbleLeftRightIcon class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
@@ -42,8 +42,8 @@
<ViewerControlsButtonToggle
v-if="allAutomationRuns.length !== 0"
v-tippy="isSmallerOrEqualSm ? undefined : summary.longSummary"
:active="activeControl === 'automate'"
@click="toggleActiveControl('automate')"
:active="activePanel === 'automate'"
@click="toggleActivePanel('automate')"
>
<!-- <PlayCircleIcon class="h-5 w-5" /> -->
<!-- {{allAutomationRuns.length}} -->
@@ -58,8 +58,8 @@
<!-- Measurements -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : measureShortcut"
:active="activeControl === 'measurements'"
v-tippy="getShortcutDisplayText(shortcuts.ToggleMeasurements)"
:active="activePanel === 'measurements'"
@click="toggleMeasurements"
>
<IconMeasurements class="h-4 w-4 md:h-5 md:w-5" />
@@ -67,27 +67,37 @@
<div class="w-8 flex gap-2">
<div class="md:hidden">
<ViewerControlsButtonToggle
:active="activeControl === 'mobileOverflow'"
@click="toggleActiveControl('mobileOverflow')"
:active="activePanel === 'mobileOverflow'"
@click="toggleActivePanel('mobileOverflow')"
>
<ChevronDoubleRightIcon
class="h-4 w-4 md:h-5 md:w-5 transition"
:class="activeControl === 'mobileOverflow' ? 'rotate-180' : ''"
:class="activePanel === 'mobileOverflow' ? 'rotate-180' : ''"
/>
</ViewerControlsButtonToggle>
</div>
<div
class="-mt-28 md:mt-0 bg-foundation md:bg-transparent md:gap-2 shadow-md md:shadow-none flex flex-col rounded-lg transition-all *:shadow-none *:py-0 *:md:shadow-md *:md:py-2"
:class="[
activeControl === 'mobileOverflow' ? '' : '-translate-x-24 md:translate-x-0'
activePanel === 'mobileOverflow' ? '' : '-translate-x-24 md:translate-x-0'
]"
>
<ViewerControlsButtonGroup>
<!-- View Modes -->
<ViewerViewModesMenu
:open="viewModesOpen"
@force-close-others="activeControl = 'none'"
@update:open="(value: boolean) => toggleActiveControl(value ? 'viewModes' : 'none')"
/>
<!-- Views -->
<ViewerViewsMenu v-tippy="isSmallerOrEqualSm ? undefined : 'Views'" />
<ViewerViewsMenu
:open="viewsOpen"
@force-close-others="activeControl = 'none'"
@update:open="(value: boolean) => toggleActiveControl(value ? 'views' : 'none')"
/>
<!-- Zoom extents -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : zoomExtentsShortcut"
v-tippy="getShortcutDisplayText(shortcuts.ZoomExtentsOrSelection)"
flat
@click="trackAndzoomExtentsOrSelection()"
>
@@ -96,14 +106,15 @@
<!-- Sun and lights -->
<ViewerSunMenu
v-tippy="isSmallerOrEqualSm ? undefined : 'Light controls'"
:open="activeControl === 'sun'"
@update:open="(value: boolean) => toggleActiveControl(value ? 'sun' : 'none')"
/>
</ViewerControlsButtonGroup>
<ViewerControlsButtonGroup>
<!-- Projection type -->
<!-- TODO (Question for fabs): How to persist state between page navigation? e.g., swap to iso mode, move out, move back, iso mode is still on in viewer but not in ui -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : projectionShortcut"
v-tippy="getShortcutDisplayText(shortcuts.ToggleProjection)"
flat
secondary
:active="isOrthoProjection"
@@ -115,7 +126,7 @@
<!-- Section Box -->
<ViewerControlsButtonToggle
v-tippy="isSmallerOrEqualSm ? undefined : sectionBoxShortcut"
v-tippy="getShortcutDisplayText(shortcuts.ToggleSectionBox)"
flat
secondary
:active="isSectionBoxVisible"
@@ -125,8 +136,11 @@
</ViewerControlsButtonToggle>
<!-- Explosion -->
<ViewerExplodeMenu v-tippy="isSmallerOrEqualSm ? undefined : 'Explode'" />
<ViewerExplodeMenu
:open="explodeOpen"
@force-close-others="activeControl = 'none'"
@update:open="(value: boolean) => toggleActiveControl(value ? 'explode' : 'none')"
/>
<!-- Settings -->
<ViewerSettingsMenu />
</ViewerControlsButtonGroup>
@@ -135,9 +149,9 @@
<ViewerControlsButtonToggle
v-show="isGendoEnabled"
v-tippy="'Real time AI rendering powered by Gendo'"
:active="activeControl === 'gendo'"
:active="activePanel === 'gendo'"
class="hover:hue-rotate-30 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-amber-200 via-violet-600 to-sky-900"
@click="toggleActiveControl('gendo')"
@click="toggleActivePanel('gendo')"
>
<img
src="~/assets/images/gendo/logo.svg"
@@ -150,7 +164,7 @@
</div>
</div>
<div
v-if="activeControl !== 'none'"
v-if="activePanel !== 'none'"
ref="resizeHandle"
class="absolute z-10 max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
:style="`left:${width - 2}px; height:${height ? height - 10 : 0}px`"
@@ -170,54 +184,54 @@
<div
ref="scrollableControlsContainer"
:class="`simple-scrollbar absolute z-10 pl-12 pr-2 md:pr-0 md:pl-14 mb-4 max-h-[calc(100dvh-4.5rem)] overflow-y-auto px-[2px] py-[2px] transition ${
activeControl !== 'none'
activePanel !== 'none'
? 'translate-x-0 opacity-100'
: '-translate-x-[100%] opacity-0'
} ${isEmbedEnabled ? 'mt-1.5' : 'mt-[3.7rem]'}`"
:style="`width: ${isMobile ? '100%' : `${width + 4}px`};`"
>
<div v-if="activeControl.length !== 0 && activeControl === 'measurements'">
<div v-if="activePanel === 'measurements'">
<KeepAlive>
<div><ViewerMeasurementsOptions @close="toggleMeasurements" /></div>
</KeepAlive>
</div>
<div v-show="resourceItems.length !== 0 && activeControl === 'models'">
<div v-show="activePanel === 'models'">
<KeepAlive>
<div>
<ViewerResourcesList
v-if="!enabled"
class="pointer-events-auto"
@loaded-more="scrollControlsToBottom"
@close="activeControl = 'none'"
@close="activePanel = 'none'"
/>
<ViewerCompareChangesPanel v-else @close="activeControl = 'none'" />
<ViewerCompareChangesPanel v-else @close="activePanel = 'none'" />
</div>
</KeepAlive>
</div>
<div v-show="resourceItems.length !== 0 && activeControl === 'explorer'">
<div v-show="resourceItems.length !== 0 && activePanel === 'explorer'">
<KeepAlive>
<ViewerExplorer class="pointer-events-auto" @close="activeControl = 'none'" />
<ViewerExplorer class="pointer-events-auto" @close="activePanel = 'none'" />
</KeepAlive>
</div>
<ViewerComments
v-if="resourceItems.length !== 0 && activeControl === 'discussions'"
v-if="resourceItems.length !== 0 && activePanel === 'discussions'"
class="pointer-events-auto"
@close="activeControl = 'none'"
@close="activePanel = 'none'"
/>
<div v-show="resourceItems.length !== 0 && activeControl === 'automate'">
<div v-show="resourceItems.length !== 0 && activePanel === 'automate'">
<AutomateViewerPanel
:automation-runs="allAutomationRuns"
:summary="summary"
@close="activeControl = 'none'"
@close="activePanel = 'none'"
/>
</div>
<div
v-if="resourceItems.length !== 0 && activeControl === 'gendo' && isGendoEnabled"
v-if="resourceItems.length !== 0 && activePanel === 'gendo' && isGendoEnabled"
>
<ViewerGendoPanel @close="activeControl = 'none'" />
<ViewerGendoPanel @close="activePanel = 'none'" />
</div>
<!-- Empty state -->
@@ -258,17 +272,12 @@ import { isNonNullable, type Nullable } from '@speckle/shared'
import {
useCameraUtilities,
useSectionBoxUtilities,
useMeasurementUtilities
useMeasurementUtilities,
useViewerShortcuts
} from '~~/lib/viewer/composables/ui'
import {
onKeyboardShortcut,
ModifierKeys,
getKeyboardShortcutTitle
} from '@speckle/ui-components'
import {
useInjectedViewerLoadedResources,
useInjectedViewerInterfaceState,
useInjectedViewerState
useInjectedViewerInterfaceState
} from '~~/lib/viewer/composables/setup'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
@@ -283,17 +292,27 @@ import {
import { useFunctionRunsStatusSummary } from '~/lib/automate/composables/runStatus'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
const isGendoEnabled = useIsGendoModuleEnabled()
type ActivePanel =
| 'none'
| 'models'
| 'explorer'
| 'discussions'
| 'automate'
| 'measurements'
| 'gendo'
| 'mobileOverflow'
enum ViewerKeyboardActions {
ToggleModels = 'ToggleModels',
ToggleExplorer = 'ToggleExplorer',
ToggleDiscussions = 'ToggleDiscussions',
ToggleMeasurements = 'ToggleMeasurements',
ToggleProjection = 'ToggleProjection',
ToggleSectionBox = 'ToggleSectionBox',
ZoomExtentsOrSelection = 'ZoomExtentsOrSelection'
}
type ActiveControl =
| 'none'
| 'viewModes'
| 'views'
| 'sun'
| 'projection'
| 'sectionBox'
| 'explode'
| 'settings'
const isGendoEnabled = useIsGendoModuleEnabled()
const width = ref(360)
const scrollableControlsContainer = ref(null as Nullable<HTMLDivElement>)
@@ -336,17 +355,6 @@ if (import.meta.client) {
})
}
type ActiveControl =
| 'none'
| 'models'
| 'explorer'
| 'filters'
| 'discussions'
| 'automate'
| 'measurements'
| 'mobileOverflow'
| 'gendo'
const { resourceItems, modelsAndVersionIds } = useInjectedViewerLoadedResources()
const {
resetSectionBox,
@@ -364,8 +372,11 @@ const {
toggleProjection,
camera: { isOrthoProjection }
} = useCameraUtilities()
const { registerShortcuts, getShortcutDisplayText, shortcuts } = useViewerShortcuts()
const { ui } = useInjectedViewerState()
const {
diff: { enabled }
} = useInjectedViewerInterfaceState()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('sm')
@@ -389,100 +400,47 @@ const { summary } = useFunctionRunsStatusSummary({
const openAddModel = ref(false)
const activeControl = ref<ActiveControl>('models')
const {
diff: { enabled }
} = useInjectedViewerInterfaceState()
const map: Record<ViewerKeyboardActions, [ModifierKeys[], string]> = {
[ViewerKeyboardActions.ToggleModels]: [[ModifierKeys.Shift], 'M'],
[ViewerKeyboardActions.ToggleExplorer]: [[ModifierKeys.Shift], 'E'],
[ViewerKeyboardActions.ToggleDiscussions]: [[ModifierKeys.Shift], 'T'],
[ViewerKeyboardActions.ToggleMeasurements]: [[ModifierKeys.Shift], 'R'],
[ViewerKeyboardActions.ToggleProjection]: [[ModifierKeys.Shift], 'P'],
[ViewerKeyboardActions.ToggleSectionBox]: [[ModifierKeys.Shift], 'B'],
[ViewerKeyboardActions.ZoomExtentsOrSelection]: [[ModifierKeys.Shift], 'space']
}
const getShortcutTitle = (action: ViewerKeyboardActions) =>
`(${getKeyboardShortcutTitle([...map[action][0], map[action][1]])})`
const modelsShortcut = ref(
`Models ${getShortcutTitle(ViewerKeyboardActions.ToggleModels)}`
)
const explorerShortcut = ref(
`Scene explorer ${getShortcutTitle(ViewerKeyboardActions.ToggleExplorer)}`
)
const discussionsShortcut = ref(
`Discussions ${getShortcutTitle(ViewerKeyboardActions.ToggleDiscussions)}`
)
const zoomExtentsShortcut = ref(
`Fit to screen ${getShortcutTitle(ViewerKeyboardActions.ZoomExtentsOrSelection)}`
)
const projectionShortcut = ref(
`Projection ${getShortcutTitle(ViewerKeyboardActions.ToggleProjection)}`
)
const sectionBoxShortcut = ref(
`Section box ${getShortcutTitle(ViewerKeyboardActions.ToggleSectionBox)}`
)
const measureShortcut = ref(
`Measure mode ${getShortcutTitle(ViewerKeyboardActions.ToggleMeasurements)}`
)
const isTypingComment = computed(() => {
const isNewThreadEditorOpen = ui.threads.openThread.newThreadEditor.value
const isExistingThreadEditorOpen = !!ui.threads.openThread.thread.value
return isNewThreadEditorOpen || isExistingThreadEditorOpen
})
const handleKeyboardAction = (action: ViewerKeyboardActions) => {
if (isTypingComment.value) {
return
}
switch (action) {
case ViewerKeyboardActions.ToggleModels:
toggleActiveControl('models')
break
case ViewerKeyboardActions.ToggleExplorer:
toggleActiveControl('explorer')
break
case ViewerKeyboardActions.ToggleDiscussions:
toggleActiveControl('discussions')
break
case ViewerKeyboardActions.ToggleMeasurements:
toggleMeasurements()
break
case ViewerKeyboardActions.ToggleProjection:
trackAndtoggleProjection()
break
case ViewerKeyboardActions.ToggleSectionBox:
toggleSectionBox()
break
case ViewerKeyboardActions.ZoomExtentsOrSelection:
trackAndzoomExtentsOrSelection()
break
}
}
Object.entries(map).forEach(([actionKey, [modifiers, key]]) => {
const action = actionKey as ViewerKeyboardActions
onKeyboardShortcut(modifiers, key, () => handleKeyboardAction(action))
})
const activeControl = ref<ActiveControl>('none')
const activePanel = ref<ActivePanel>('none')
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const toggleActiveControl = (control: ActiveControl) => {
const isMeasurementsActive = activeControl.value === 'measurements'
if (isMeasurementsActive && control !== 'measurements') {
const toggleActivePanel = (panel: ActivePanel) => {
const isMeasurementsActive = activePanel.value === 'measurements'
if (isMeasurementsActive && panel !== 'measurements') {
enableMeasurements(false)
}
// Special handling for mobile overflow
if (isSmallerOrEqualSm.value && panel === 'mobileOverflow') {
activePanel.value =
activePanel.value === 'mobileOverflow' ? 'none' : 'mobileOverflow'
} else {
activePanel.value = activePanel.value === panel ? 'none' : panel
}
}
const toggleActiveControl = (control: ActiveControl) => {
activeControl.value = activeControl.value === control ? 'none' : control
}
registerShortcuts({
ToggleModels: () => toggleActivePanel('models'),
ToggleExplorer: () => toggleActivePanel('explorer'),
ToggleDiscussions: () => toggleActivePanel('discussions'),
ToggleMeasurements: () => toggleMeasurements(),
ToggleProjection: () => trackAndtoggleProjection(),
ToggleSectionBox: () => toggleSectionBox(),
ZoomExtentsOrSelection: () => trackAndzoomExtentsOrSelection()
})
const mp = useMixpanel()
watch(activeControl, (newVal) => {
mp.track('Viewer Action', { type: 'action', name: 'controls-toggle', action: newVal })
mp.track('Viewer Action', {
type: 'action',
name: 'controls-toggle',
action: newVal
})
})
const trackAndzoomExtentsOrSelection = () => {
@@ -506,30 +464,32 @@ const scrollControlsToBottom = () => {
}
const toggleMeasurements = () => {
const isMeasurementsActive = activeControl.value === 'measurements'
const isMeasurementsActive = activePanel.value === 'measurements'
enableMeasurements(!isMeasurementsActive)
activeControl.value = isMeasurementsActive ? 'none' : 'measurements'
activePanel.value = isMeasurementsActive ? 'none' : 'measurements'
}
onMounted(() => {
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
})
onKeyStroke('Escape', () => {
const isActiveMeasurement = getActiveMeasurement()
if (isActiveMeasurement) {
removeMeasurement()
} else {
if (activeControl.value === 'measurements') {
if (activePanel.value === 'measurements') {
toggleMeasurements()
}
activePanel.value = 'none'
activeControl.value = 'none'
}
})
onMounted(() => {
// Set initial panel state after component is mounted
activePanel.value = isSmallerOrEqualSm.value ? 'none' : 'models'
})
watch(isSmallerOrEqualSm, (newVal) => {
activeControl.value = newVal ? 'none' : 'models'
activePanel.value = newVal ? 'none' : 'models'
})
watch(isSectionBoxEnabled, (val) => {
@@ -547,4 +507,25 @@ watch(isSectionBoxVisible, (val) => {
status: val
})
})
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 explodeOpen = computed({
get: () => activeControl.value === 'explode',
set: (value) => {
activeControl.value = value ? 'explode' : 'none'
}
})
</script>
@@ -1,53 +1,46 @@
<template>
<div class="relative z-30">
<Popover as="div" class="relative z-30">
<PopoverButton v-slot="{ open }" as="template">
<ViewerControlsButtonToggle flat secondary :active="open || isActive">
<!-- <ChevronUpDownIcon class="w-5 h-5 rotate-45" /> -->
<IconExplode class="h-4 w-4 sm:h-5 sm:w-5" />
</ViewerControlsButtonToggle>
</PopoverButton>
<Transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<PopoverPanel
class="absolute translate-x-0 left-12 top-0 p-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col space-y-2"
>
<div class="flex items-center space-x-1">
<input
id="intensity"
v-model="explodeFactor"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="intensity"
min="0"
max="1"
step="0.01"
/>
<label class="text-body-xs text-foreground-2" for="intensity">
Intensity
</label>
</div>
</PopoverPanel>
</Transition>
</Popover>
</div>
<ViewerMenu
v-model:open="open"
v-tippy="isSmallerOrEqualSm ? undefined : 'Explode'"
tooltip="Explode model"
>
<template #trigger-icon>
<IconExplode
class="h-4 w-4 sm:h-5 sm:w-5"
:class="{ 'text-foreground-2': !isActive }"
/>
</template>
<div class="w-56 p-2 flex flex-col space-y-2">
<div class="flex items-center space-x-1">
<input
id="intensity"
v-model="explodeFactor"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="intensity"
min="0"
max="1"
step="0.01"
/>
<label class="text-body-xs text-foreground-2" for="intensity">Intensity</label>
</div>
</div>
</ViewerMenu>
</template>
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { useMixpanel } from '~~/lib/core/composables/mp'
// import { ChevronUpDownIcon } from '@heroicons/vue/24/outline'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
const open = defineModel<boolean>('open', { required: true })
const {
ui: { explodeFactor }
} = useInjectedViewerState()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const isActive = computed(() => {
return explodeFactor.value > 0.01
})
@@ -0,0 +1,23 @@
<template>
<button
class="flex items-center justify-between hover:bg-highlight-1 text-foreground w-full h-full text-body-2xs sm:text-body-xs py-1 px-2 rounded-md"
:class="{ 'bg-highlight-1': active }"
>
<div v-if="!hideActiveTick" class="w-5 shrink-0">
<IconCheck v-if="active" class="h-4 w-4 text-foreground-2" />
</div>
<div class="flex-1 text-left">{{ label }}</div>
<span v-if="shortcut" class="text-body-2xs text-foreground-2">
{{ shortcut }}
</span>
</button>
</template>
<script setup lang="ts">
defineProps<{
label: string
active?: boolean
hideActiveTick?: boolean
shortcut?: string
}>()
</script>
@@ -0,0 +1,48 @@
<template>
<div ref="menuWrapper" class="relative z-30">
<ViewerControlsButtonToggle
v-tippy="tooltip"
flat
secondary
:active="open"
@click="toggleMenu"
>
<slot name="trigger-icon" />
</ViewerControlsButtonToggle>
<div
v-if="open"
ref="menuContent"
v-keyboard-clickable
class="absolute left-10 sm:left-12 -top-0 bg-foundation max-h-64 simple-scrollbar overflow-y-auto rounded-lg shadow-md flex flex-col"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
defineProps<{
tooltip?: string
}>()
const open = defineModel<boolean>('open', { default: false })
const menuContent = ref<HTMLElement | null>(null)
const menuWrapper = ref<HTMLElement | null>(null)
const toggleMenu = () => {
open.value = !open.value
}
onClickOutside(
menuContent,
(event) => {
if (!menuWrapper.value?.contains(event.target as Node)) {
open.value = false
}
},
{ ignore: [menuWrapper] }
)
</script>
@@ -1,105 +1,111 @@
<template>
<div class="relative z-30">
<Popover as="div" class="relative z-30">
<PopoverButton v-slot="{ open }" as="template">
<ViewerControlsButtonToggle flat secondary :active="open">
<SunIcon class="w-5 h-5" />
</ViewerControlsButtonToggle>
</PopoverButton>
<Transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<PopoverPanel
class="absolute translate-x-0 left-10 sm:left-12 top-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col space-y-2"
>
<div class="p-2 border-b border-outline flex gap-2 items-center">
<div class="scale-90">
<FormSwitch
v-model="sunlightShadows"
name="sunShadows"
:show-label="false"
/>
</div>
<span class="text-foreground text-body-sm">Sun shadows</span>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="intensity"
v-model="intensity"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="intensity"
min="1"
max="10"
step="0.05"
/>
<label class="text-body-xs text-foreground-2" for="intensity">
Intensity
</label>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="elevation"
v-model="elevation"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="elevation"
min="0"
:max="Math.PI"
step="0.05"
/>
<label class="text-body-xs text-foreground-2" for="elevation">
Elevation
</label>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="azimuth"
v-model="azimuth"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="azimuth"
:min="-Math.PI * 0.5"
:max="Math.PI * 0.5"
step="0.05"
/>
<label class="text-body-xs text-foreground-2" for="azimuth">Azimuth</label>
</div>
<div class="flex items-center gap-1 px-2 pb-2">
<input
id="indirect"
v-model="indirectLightIntensity"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="indirect"
min="0"
max="5"
step="0.05"
/>
<label class="text-body-xs text-foreground-2" for="indirect">
Indirect
</label>
</div>
</PopoverPanel>
</Transition>
</Popover>
</div>
<ViewerMenu
v-model:open="open"
tooltip="Light controls"
:disabled="!isLightingSupported"
:disabled-tooltip="'Light controls are only available in Default and Default with Edges view modes'"
>
<template #trigger-icon>
<SunIcon class="w-5 h-5" :class="{ 'text-foreground-3': !isLightingSupported }" />
</template>
<div class="flex flex-col gap-1.5">
<div v-if="!isLightingSupported" class="-mb-1 p-2 pb-0">
<CommonAlert size="xs" color="info">
<template #title>
<span class="block text-body-2xs">Not available in current view mode.</span>
</template>
</CommonAlert>
</div>
<div class="px-2 py-1 border-b border-outline flex gap-2 items-center">
<FormSwitch
v-model="sunlightShadows"
name="sunShadows"
:show-label="false"
:disabled="!isLightingSupported"
/>
<span class="text-foreground text-body-xs">Sun shadows</span>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="intensity"
v-model="intensity"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="intensity"
min="1"
max="10"
step="0.05"
:disabled="!isLightingSupported"
/>
<label class="text-body-xs text-foreground-2" for="intensity">Intensity</label>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="elevation"
v-model="elevation"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="elevation"
min="0"
:max="Math.PI"
step="0.05"
:disabled="!isLightingSupported"
/>
<label class="text-body-xs text-foreground-2" for="elevation">Elevation</label>
</div>
<div class="flex items-center gap-1 px-2">
<input
id="azimuth"
v-model="azimuth"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="azimuth"
:min="-Math.PI * 0.5"
:max="Math.PI * 0.5"
step="0.05"
:disabled="!isLightingSupported"
/>
<label class="text-body-xs text-foreground-2" for="azimuth">Azimuth</label>
</div>
<div class="flex items-center gap-1 px-2 pb-2">
<input
id="indirect"
v-model="indirectLightIntensity"
class="w-24 sm:w-32 h-2 mr-2"
type="range"
name="indirect"
min="0"
max="5"
step="0.05"
:disabled="!isLightingSupported"
/>
<label class="text-body-xs text-foreground-2" for="indirect">Indirect</label>
</div>
</div>
</ViewerMenu>
</template>
<script setup lang="ts">
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import type { SunLightConfiguration } from '@speckle/viewer'
import { ViewMode, type SunLightConfiguration } from '@speckle/viewer'
import { SunIcon } from '@heroicons/vue/24/outline'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { debounce } from 'lodash-es'
import { FormSwitch } from '@speckle/ui-components'
import { useViewModeUtilities } from '~/lib/viewer/composables/ui'
const open = defineModel<boolean>('open', { required: true })
const mp = useMixpanel()
const { currentViewMode } = useViewModeUtilities()
const isLightingSupported = computed(() => {
const supported =
currentViewMode.value === ViewMode.DEFAULT ||
currentViewMode.value === ViewMode.DEFAULT_EDGES
return supported
})
const debounceTrackLightConfigChange = debounce(() => {
mp.track('Viewer Action', {
type: 'action',
@@ -0,0 +1,86 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" tooltip="View modes">
<template #trigger-icon>
<IconViewModes class="h-5 w-5" />
</template>
<div
class="w-56 p-1.5"
@mouseenter="cancelCloseTimer"
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
@focusin="cancelCloseTimer"
@focusout="isManuallyOpened ? undefined : startCloseTimer"
>
<div v-for="shortcut in viewModeShortcuts" :key="shortcut.name">
<ViewerMenuItem
:label="shortcut.name"
:active="isActiveMode(shortcut.viewMode)"
:shortcut="getShortcutDisplayText(shortcut, { hideName: true })"
@click="handleViewModeChange(shortcut.viewMode)"
/>
</div>
</div>
</ViewerMenu>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { ViewMode } from '@speckle/viewer'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useViewerShortcuts, useViewModeUtilities } from '~~/lib/viewer/composables/ui'
import { ViewModeShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
const open = defineModel<boolean>('open', { default: false })
const { setViewMode, currentViewMode } = useViewModeUtilities()
const { getShortcutDisplayText, registerShortcuts } = useViewerShortcuts()
const mp = useMixpanel()
const isManuallyOpened = ref(false)
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
() => {
open.value = false
},
3000,
{ immediate: false }
)
registerShortcuts({
SetViewModeDefault: () => handleViewModeChange(ViewMode.DEFAULT, true),
SetViewModeDefaultEdges: () => handleViewModeChange(ViewMode.DEFAULT_EDGES, true),
SetViewModeShaded: () => handleViewModeChange(ViewMode.SHADED, true),
SetViewModePen: () => handleViewModeChange(ViewMode.PEN, true),
SetViewModeArctic: () => handleViewModeChange(ViewMode.ARCTIC, true),
SetViewModeColors: () => handleViewModeChange(ViewMode.COLORS, true)
})
const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value
const viewModeShortcuts = Object.values(ViewModeShortcuts)
const emit = defineEmits<{
(e: 'force-close-others'): void
}>()
const handleViewModeChange = (mode: ViewMode, isShortcut = false) => {
setViewMode(mode)
cancelCloseTimer()
if (isShortcut) {
emit('force-close-others')
open.value = true
startCloseTimer()
}
mp.track('Viewer Action', {
type: 'action',
name: 'set-view-mode',
mode
})
}
onUnmounted(() => {
cancelCloseTimer()
})
</script>
@@ -1,52 +1,48 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div ref="menuWrapper" class="relative z-30">
<ViewerControlsButtonToggle flat secondary :active="open" @click="open = !open">
<ViewerMenu v-model:open="open" tooltip="Views">
<template #trigger-icon>
<IconViews class="w-5 h-5" />
</ViewerControlsButtonToggle>
<Transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
</template>
<div
class="w-32 sm:w-40 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-if="open"
class="absolute translate-x-0 w-32 left-10 sm:left-12 -top-0 sm:-top-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col"
>
<!-- Canonical views first -->
<div v-for="view in canonicalViews" :key="view.name">
<button
class="hover:bg-primary-muted text-foreground w-full h-full text-body-xs py-1"
@click="setView(view.name.toLowerCase() as CanonicalView)"
>
{{ view.name }}
</button>
</div>
<div v-if="views.length !== 0" class="w-full border-b"></div>
<!-- Any model other views -->
<div v-for="view in views" :key="view.id">
<button
class="hover:bg-primary-muted text-foreground w-full h-full text-body-xs py-1 transition"
:title="view.name"
@click="setView(view)"
>
<span class="block truncate max-w-28 mx-auto">
{{ view.name ? view.name : view.id }}
</span>
</button>
</div>
<div v-for="shortcut in viewShortcuts" :key="shortcut.name">
<ViewerMenuItem
:label="shortcut.name"
hide-active-tick
:active="activeView === shortcut.name.toLowerCase()"
:shortcut="getShortcutDisplayText(shortcut, { hideName: true })"
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
/>
</div>
</Transition>
</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 type { CanonicalView, SpeckleView } from '~~/../viewer/dist'
import { useTimeoutFn } from '@vueuse/core'
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
import { onClickOutside } from '@vueuse/core'
import { useCameraUtilities, useViewerShortcuts } from '~~/lib/viewer/composables/ui'
import { ViewShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
import { useViewerCameraControlEndTracker } from '~~/lib/viewer/composables/viewer'
const {
viewer: {
@@ -54,14 +50,37 @@ const {
}
} = useInjectedViewerState()
const { setView: setViewRaw } = useCameraUtilities()
const { getShortcutDisplayText, registerShortcuts } = useViewerShortcuts()
const mp = useMixpanel()
const open = ref(false)
const open = defineModel<boolean>('open', { default: false })
const isManuallyOpened = ref(false)
const activeView = ref<string | null>(null)
const menuWrapper = ref(null)
// Clear active view when camera control ends
useViewerCameraControlEndTracker(() => {
activeView.value = null
})
const setView = (v: CanonicalView | SpeckleView) => {
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
() => {
open.value = false
},
3000,
{ immediate: false }
)
const handleViewChange = (v: CanonicalView | SpeckleView, isShortcut = false) => {
setViewRaw(v)
cancelCloseTimer()
activeView.value = typeof v === 'string' ? v : v.id
if (isShortcut) {
emit('force-close-others')
open.value = true
startCloseTimer()
}
mp.track('Viewer Action', {
type: 'action',
name: 'set-view',
@@ -69,15 +88,21 @@ const setView = (v: CanonicalView | SpeckleView) => {
})
}
const canonicalViews = [
{ name: 'Top' },
{ name: 'Front' },
{ name: 'Left' },
{ name: 'Back' },
{ name: 'Right' }
]
registerShortcuts({
SetViewTop: () => handleViewChange('top', true),
SetViewFront: () => handleViewChange('front', true),
SetViewLeft: () => handleViewChange('left', true),
SetViewBack: () => handleViewChange('back', true),
SetViewRight: () => handleViewChange('right', true)
})
onClickOutside(menuWrapper, () => {
open.value = false
const viewShortcuts = Object.values(ViewShortcuts)
const emit = defineEmits<{
(e: 'force-close-others'): void
}>()
onUnmounted(() => {
cancelCloseTimer()
})
</script>
@@ -94,19 +94,19 @@ export const useAutomationRunDetailsFns = () => {
switch (status) {
case AutomateRunStatus.Pending:
case AutomateRunStatus.Initializing:
classParts.push('bg-warning-lighter text-warning-darker')
classParts.push('bg-warning-lighter')
break
case AutomateRunStatus.Running:
classParts.push('bg-info-lighter text-info-darker')
classParts.push('bg-info-lighter')
break
case AutomateRunStatus.Failed:
case AutomateRunStatus.Exception:
case AutomateRunStatus.Canceled:
case AutomateRunStatus.Timeout:
classParts.push('bg-danger-lighter text-danger-darker')
classParts.push('bg-danger-lighter')
break
case AutomateRunStatus.Succeeded:
classParts.push('bg-success-lighter text-success-darker')
classParts.push('bg-success-lighter')
break
}
@@ -199,12 +199,24 @@ export const useBillingActions = () => {
type: ToastNotificationType.Danger,
title: 'Your payment was canceled'
})
mixpanel.track('Workspace Upgrade Cancelled', {
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Your workspace plan was successfully updated'
})
mixpanel.track('Workspace Upgraded', {
plan: workspace.plan?.name,
cycle: workspace.subscription?.billingInterval,
// eslint-disable-next-line camelcase
workspace_id: workspace.id
})
if (import.meta.server) {
await sendWebhook(defaultZapierWebhookUrl, {
workspaceId: workspace.id,
@@ -1,60 +1,93 @@
import { useTimeoutFn } from '@vueuse/core'
import type { Optional } from '@speckle/shared'
import type { ToastNotification } from '@speckle/ui-components'
import { useTimeoutFn } from '@vueuse/core'
import { ToastNotificationType } from '@speckle/ui-components'
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
import { nanoid } from 'nanoid'
/**
* Persisting toast state between reqs and between CSR & SSR loads so that we can trigger
* toasts anywhere and anytime
*/
const useGlobalToastState = () =>
useSynchronizedCookie<Optional<ToastNotification>>('global-toast-state')
useSynchronizedCookie<Optional<ToastNotification[]>>('global-toast-state')
/**
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
*/
export function useGlobalToastManager() {
const stateNotification = useGlobalToastState()
const currentNotification = ref(stateNotification.value)
const readOnlyNotification = computed(() => currentNotification.value)
const dismiss = () => {
currentNotification.value = undefined
stateNotification.value = undefined
type Timeout = {
id: string
stop: () => void
}
const { start, stop } = useTimeoutFn(() => {
dismiss()
}, 4000)
const stateNotification = useGlobalToastState()
const timeouts = ref<Timeout[]>([])
const currentNotifications = ref<ToastNotification[]>(
Array.isArray(stateNotification.value) ? stateNotification.value : []
)
const readOnlyNotification = computed(() => currentNotifications.value)
// Remove a specific notification from the state
const removeNotification = (id: string) => {
const index = currentNotifications.value.findIndex((n) => n.id === id)
if (index !== -1) {
currentNotifications.value.splice(index, 1)
// Clean up timeout
timeouts.value = timeouts.value.filter((t) => t.id !== id)
}
}
// Create a timeout for a notification
const createTimeout = (notification: ToastNotification) => {
const { stop } = useTimeoutFn(() => {
if (notification.id) {
removeNotification(notification.id)
}
}, 4000)
return stop
}
watch(
stateNotification,
(newVal) => {
if (!newVal) return
if (import.meta.server) {
currentNotification.value = newVal
return
currentNotifications.value = newVal
// Create timeout for the new notification
const index = currentNotifications.value.length - 1
const lastNotification = newVal[index]
if (lastNotification && !lastNotification.autoClose) {
timeouts.value.push({
id: lastNotification.id as string,
stop: createTimeout(lastNotification)
})
}
// First dismiss old notification, then set a new one on next tick
// this is so that the old one actually disappears from the screen for the user,
// instead of just having its contents replaced
dismiss()
nextTick(() => {
currentNotification.value = newVal
// (re-)init timeout
stop()
if (newVal.autoClose !== false) start()
})
},
{ deep: true, immediate: true }
)
return { currentNotification: readOnlyNotification, dismiss }
// Function to dismiss a specific notification
const dismiss = (notification: ToastNotification) => {
if (!notification.id) return
const targetTimeout = timeouts.value.find((t) => t.id === notification.id)
if (targetTimeout) {
targetTimeout.stop()
}
removeNotification(notification.id as string)
}
// Dismiss all notifications
const dismissAll = () => {
timeouts.value.forEach((timeout) => timeout.stop())
timeouts.value = []
currentNotifications.value = []
}
return { currentNotifications: readOnlyNotification, dismiss, dismissAll }
}
/**
@@ -68,7 +101,11 @@ export function useGlobalToast() {
* Trigger a new toast notification
*/
const triggerNotification = (notification: ToastNotification) => {
stateNotification.value = notification
const newNotification = { ...notification, id: nanoid() }
stateNotification.value
? stateNotification.value.push(newNotification)
: (stateNotification.value = [newNotification])
if (import.meta.server) {
logger.info('Queued SSR toast notification', notification)
@@ -83,7 +83,7 @@ const documents = {
"\n query ProjectPageSettingsCollaboratorsWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...ProjectPageTeamInternals_Workspace\n }\n }\n": types.ProjectPageSettingsCollaboratorsWorkspaceDocument,
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n role\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": types.ProjectPageSettingsGeneralDocument,
"\n fragment ProjectPageSettingsGeneralBlockAccess_Project on Project {\n id\n visibility\n }\n": types.ProjectPageSettingsGeneralBlockAccess_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n": types.ProjectPageSettingsGeneralBlockDelete_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n": types.ProjectPageSettingsGeneralBlockDelete_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsGeneralBlockDiscussions_Project on Project {\n id\n visibility\n allowPublicComments\n }\n": types.ProjectPageSettingsGeneralBlockDiscussions_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsGeneralBlockLeave_Project on Project {\n id\n name\n role\n team {\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n workspace {\n id\n }\n }\n": types.ProjectPageSettingsGeneralBlockLeave_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsGeneralBlockProjectInfo_Project on Project {\n id\n role\n name\n description\n }\n": types.ProjectPageSettingsGeneralBlockProjectInfo_ProjectFragmentDoc,
@@ -674,7 +674,7 @@ export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockAcc
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n"];
export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
import type { InviteServerItem } from '~~/lib/invites/helpers/types'
import { Roles } from '@speckle/shared'
export const emptyInviteServerItem: InviteServerItem = {
email: '',
serverRole: Roles.Server.User,
project: undefined
}
@@ -0,0 +1,12 @@
import type { ServerRoles } from '@speckle/shared'
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
export type InviteServerItem = {
email: string
serverRole: ServerRoles
project?: FormSelectProjects_ProjectFragment
}
export interface InviteServerForm {
fields: InviteServerItem[]
}
@@ -308,13 +308,19 @@ export function useInviteUserToProject() {
if (err) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Invitation failed',
title:
input.length > 1
? "Couldn't send invites"
: `Coudldn't send invite to ${input[0].email}`,
description: err
})
} else {
triggerNotification({
type: ToastNotificationType.Success,
title: 'Invite successfully sent'
title:
input.length > 1
? 'Invites successfully send'
: `Invite successfully sent to ${input[0].email}`
})
}
@@ -55,13 +55,19 @@ export function useInviteUserToServer() {
if (res?.data?.serverInviteBatchCreate) {
triggerNotification({
type: ToastNotificationType.Success,
title: `Server invite${finalInput.length > 1 ? 's' : ''} sent`
title:
finalInput.length > 1
? 'Server invites sent'
: `Server invite sent to ${finalInput[0].email}`
})
} else {
const errMsg = getFirstErrorMessage(res?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: `Couldn't send invite${finalInput.length > 1 ? 's' : ''}`,
title:
finalInput.length > 1
? "Couldn't send invites"
: `Couldn't send invite to ${finalInput[0].email}`,
description: errMsg
})
}
@@ -10,7 +10,7 @@ import {
useFilterUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { CameraController } from '@speckle/viewer'
import { CameraController, ViewMode } from '@speckle/viewer'
import type { NumericPropertyInfo } from '@speckle/viewer'
type SerializedViewerState = SpeckleViewer.ViewerState.SerializedViewerState
@@ -105,6 +105,7 @@ export function useStateSerialization() {
isOrthoProjection: state.ui.camera.isOrthoProjection.value,
zoom: (get(camControls, '_zoom') as number) || 1 // kinda hacky, _zoom is a protected prop
},
viewMode: state.ui.viewMode.value,
sectionBox: state.ui.sectionBox.value
? {
min: box.min.toArray(),
@@ -141,7 +142,8 @@ export function useApplySerializedState() {
highlightedObjectIds,
explodeFactor,
lightConfig,
diff
diff,
viewMode
},
resources: {
request: { resourceIdString }
@@ -282,6 +284,13 @@ export function useApplySerializedState() {
await endDiff()
}
// Restore view mode
if (state.ui.viewMode) {
viewMode.value = state.ui.viewMode
} else {
viewMode.value = ViewMode.DEFAULT
}
explodeFactor.value = state.ui.explodeFactor
lightConfig.value = {
...lightConfig.value,
@@ -6,16 +6,17 @@ import {
MeasurementType,
FilteringExtension
} from '@speckle/viewer'
import type {
FilteringState,
PropertyInfo,
SunLightConfiguration,
SpeckleView,
MeasurementOptions,
DiffResult,
Viewer,
WorldTree,
VisualDiffMode
import {
type FilteringState,
type PropertyInfo,
type SunLightConfiguration,
type SpeckleView,
type MeasurementOptions,
type DiffResult,
type Viewer,
type WorldTree,
type VisualDiffMode,
ViewMode
} from '@speckle/viewer'
import type { MaybeRef } from '@vueuse/shared'
import { inject, ref, provide } from 'vue'
@@ -66,7 +67,6 @@ import {
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import { buildManualPromise } from '@speckle/ui-components'
import { PassReader } from '../extensions/PassReader'
import { ViewModesKeys } from '../extensions/ViewModesKeys'
export type LoadedModel = NonNullable<
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
@@ -259,6 +259,7 @@ export type InjectableViewerState = Readonly<{
target: Ref<Vector3>
isOrthoProjection: Ref<boolean>
}
viewMode: Ref<ViewMode>
diff: {
newVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
oldVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
@@ -338,7 +339,6 @@ function createViewerDataBuilder(params: { viewerDebug: boolean }) {
verbose: !!(import.meta.client && params.viewerDebug)
})
viewer.createExtension(PassReader)
viewer.createExtension(ViewModesKeys)
const initPromise = viewer.init()
return {
@@ -931,6 +931,7 @@ function setupInterfaceState(
if (explodeFactor.value !== 0) return true
return false
})
const viewMode = ref<ViewMode>(ViewMode.DEFAULT)
const highlightedObjectIds = ref([] as string[])
const spotlightUserSessionId = ref(null as Nullable<string>)
@@ -993,6 +994,7 @@ function setupInterfaceState(
target,
isOrthoProjection
},
viewMode,
sectionBox: ref(null as Nullable<Box3>),
sectionBoxContext: {
visible: ref(false),
@@ -1078,7 +1080,7 @@ export function useInjectedViewerInterfaceState(): InjectableViewerState['ui'] {
export function useResetUiState() {
const {
ui: { camera, sectionBox, highlightedObjectIds, lightConfig }
ui: { camera, sectionBox, highlightedObjectIds, lightConfig, viewMode }
} = useInjectedViewerState()
const { resetFilters } = useFilterUtilities()
const { endDiff } = useDiffUtilities()
@@ -1088,6 +1090,7 @@ export function useResetUiState() {
sectionBox.value = null
highlightedObjectIds.value = []
lightConfig.value = { ...DefaultLightConfiguration }
viewMode.value = ViewMode.DEFAULT
resetFilters()
endDiff()
}
@@ -1,4 +1,10 @@
import { difference, flatten, isEqual, uniq } from 'lodash-es'
import {
ViewMode,
type PropertyInfo,
type StringPropertyInfo,
type SunLightConfiguration
} from '@speckle/viewer'
import {
ViewerEvent,
VisualDiffMode,
@@ -6,12 +12,9 @@ import {
UpdateFlags,
SectionOutlines,
SectionToolEvent,
SectionTool
} from '@speckle/viewer'
import type {
PropertyInfo,
StringPropertyInfo,
SunLightConfiguration
SectionTool,
ViewModes,
ViewModeEvent
} from '@speckle/viewer'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
import type {
@@ -636,6 +639,44 @@ function useViewerFiltersIntegration() {
)
}
function useViewerViewModeIntegration() {
const {
ui: { viewMode },
viewer: { instance }
} = useInjectedViewerState()
const viewModes = instance.getExtension(ViewModes)
const onViewModeChanged = (mode: ViewMode) => {
viewMode.value = mode
}
onMounted(() => {
if (!viewMode.value) {
viewMode.value = ViewMode.DEFAULT
}
viewModes.on(ViewModeEvent.Changed, onViewModeChanged)
})
onBeforeUnmount(() => {
// Reset view mode to default
viewModes.setViewMode(ViewMode.DEFAULT)
viewMode.value = ViewMode.DEFAULT
// Clean up event listener
viewModes.removeListener(ViewModeEvent.Changed, onViewModeChanged)
})
watch(
() => viewMode.value,
(newMode) => {
if (viewModes && newMode) {
viewModes.setViewMode(newMode)
}
},
{ immediate: true }
)
}
function useLightConfigIntegration() {
const {
ui: { lightConfig },
@@ -874,6 +915,7 @@ export function useViewerPostSetup() {
useViewerSectionBoxIntegration()
useViewerCameraIntegration()
useViewerFiltersIntegration()
useViewerViewModeIntegration()
useLightConfigIntegration()
useExplodeFactorIntegration()
useDiffingIntegration()
@@ -1,9 +1,14 @@
import { SpeckleViewer, timeoutAt } from '@speckle/shared'
import type { TreeNode, MeasurementOptions, PropertyInfo } from '@speckle/viewer'
import { MeasurementsExtension } from '@speckle/viewer'
import type {
TreeNode,
MeasurementOptions,
PropertyInfo,
ViewMode
} from '@speckle/viewer'
import { MeasurementsExtension, ViewModes } from '@speckle/viewer'
import { until } from '@vueuse/shared'
import { difference, isString, uniq } from 'lodash-es'
import { useEmbedState } from '~/lib/viewer/composables/setup/embed'
import { useEmbedState, useEmbed } from '~/lib/viewer/composables/setup/embed'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import { isNonNullable } from '~~/lib/common/helpers/utils'
import {
@@ -15,6 +20,13 @@ import {
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
import { useTourStageState } from '~~/lib/viewer/composables/tour'
import { Vector3, Box3 } from 'three'
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
import type {
ViewerShortcut,
ViewerShortcutAction
} from '~/lib/viewer/helpers/shortcuts/types'
import { useActiveElement } from '@vueuse/core'
export function useSectionBoxUtilities() {
const { instance } = useInjectedViewer()
@@ -475,3 +487,93 @@ export function useHighlightedObjectsUtilities() {
clearHighlightedObjects
}
}
export function useViewModeUtilities() {
const { instance } = useInjectedViewer()
const { viewMode } = useInjectedViewerInterfaceState()
const currentViewMode = computed(() => viewMode.value)
const setViewMode = (mode: ViewMode) => {
const viewModes = instance.getExtension(ViewModes)
if (viewModes) {
viewModes.setViewMode(mode)
}
}
return {
currentViewMode,
setViewMode
}
}
export function useViewerShortcuts() {
const { ui } = useInjectedViewerState()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const { isEnabled: isEmbedEnabled } = useEmbed()
const activeElement = useActiveElement()
const isTypingComment = computed(() => {
if (
activeElement.value &&
(activeElement.value.tagName.toLowerCase() === 'input' ||
activeElement.value.tagName.toLowerCase() === 'textarea' ||
activeElement.value.getAttribute('contenteditable') === 'true')
) {
return true
}
// Check thread editor states
const isNewThreadEditorOpen = ui.threads.openThread.newThreadEditor.value
const isExistingThreadEditorOpen = !!ui.threads.openThread.thread.value
return isNewThreadEditorOpen || isExistingThreadEditorOpen
})
const formatKey = (key: string) => {
if (key.startsWith('Digit')) {
return key.slice(5)
}
return key
}
const getShortcutDisplayText = (
shortcut: ViewerShortcut,
options?: { hideName?: boolean }
) => {
if (isSmallerOrEqualSm.value) return undefined
if (isEmbedEnabled.value) return undefined
const shortcutText = getKeyboardShortcutTitle([
...shortcut.modifiers,
formatKey(shortcut.key)
])
if (!options?.hideName) {
return `${shortcut.name} (${shortcutText})`
}
return shortcutText
}
const disableShortcuts = computed(() => isTypingComment.value || isEmbedEnabled.value)
const registerShortcuts = (
handlers: Partial<Record<ViewerShortcutAction, () => void>>
) => {
Object.values(ViewerShortcuts).forEach((shortcut) => {
const handler = handlers[shortcut.action as ViewerShortcutAction]
if (handler) {
onKeyboardShortcut([...shortcut.modifiers], shortcut.key, () => {
if (!disableShortcuts.value) handler()
})
}
})
}
return {
shortcuts: ViewerShortcuts,
registerShortcuts,
getShortcutDisplayText
}
}
@@ -1,48 +0,0 @@
import {
Extension,
InputEvent,
ViewMode,
ViewModes,
type IViewer
} from '@speckle/viewer'
export class ViewModesKeys extends Extension {
public get inject() {
return [ViewModes]
}
constructor(viewer: IViewer, protected viewModes: ViewModes) {
super(viewer)
const renderer = viewer.getRenderer()
renderer.input.on(InputEvent.KeyUp, (arg: KeyboardEvent) => {
// Dont trigger on inputs, textareas or contenteditable elements
// We should handle this more gracefully but it works for now
if (
arg.target &&
((arg.target as HTMLElement).tagName.toLowerCase() === 'input' ||
(arg.target as HTMLElement).tagName.toLowerCase() === 'textarea' ||
(arg.target as HTMLElement).getAttribute('contenteditable') === 'true')
)
return
switch (arg.key) {
case '1':
viewModes.setViewMode(ViewMode.DEFAULT)
break
case '2':
viewModes.setViewMode(ViewMode.DEFAULT_EDGES)
break
case '3':
viewModes.setViewMode(ViewMode.SHADED)
break
case '4':
viewModes.setViewMode(ViewMode.PEN)
break
case '5':
viewModes.setViewMode(ViewMode.ARCTIC)
break
}
})
}
}
@@ -0,0 +1,153 @@
import { ModifierKeys } from '@speckle/ui-components'
import { ViewMode } from '@speckle/viewer'
export const PanelShortcuts = {
ToggleModels: {
name: 'Models',
description: 'Toggle models panel',
modifiers: [ModifierKeys.Shift],
key: 'M',
action: 'ToggleModels'
},
ToggleExplorer: {
name: 'Scene explorer',
description: 'Toggle scene explorer panel',
modifiers: [ModifierKeys.Shift],
key: 'E',
action: 'ToggleExplorer'
},
ToggleDiscussions: {
name: 'Discussions',
description: 'Toggle discussions panel',
modifiers: [ModifierKeys.Shift],
key: 'D',
action: 'ToggleDiscussions'
}
} as const
export const ToolShortcuts = {
ToggleMeasurements: {
name: 'Measure',
description: 'Toggle measurement mode',
modifiers: [ModifierKeys.Shift],
key: 'R',
action: 'ToggleMeasurements'
},
ToggleProjection: {
name: 'Projection',
description: 'Toggle between orthographic and perspective projection',
modifiers: [ModifierKeys.Shift],
key: 'P',
action: 'ToggleProjection'
},
ToggleSectionBox: {
name: 'Section',
description: 'Toggle section box',
modifiers: [ModifierKeys.Shift],
key: 'B',
action: 'ToggleSectionBox'
},
ZoomExtentsOrSelection: {
name: 'Fit',
description: 'Zoom to fit selection or entire model',
modifiers: [ModifierKeys.Shift],
key: 'space',
action: 'ZoomExtentsOrSelection'
}
} as const
export const ViewModeShortcuts = {
SetViewModeDefault: {
name: 'Rendered',
description: 'Set view mode to Rendered',
modifiers: [ModifierKeys.Shift],
key: 'Digit1',
action: 'SetViewModeDefault',
viewMode: ViewMode.DEFAULT
},
SetViewModeDefaultEdges: {
name: 'Rendered + Edges',
description: 'Set view mode to Rendered + Edges',
modifiers: [ModifierKeys.Shift],
key: 'Digit2',
action: 'SetViewModeDefaultEdges',
viewMode: ViewMode.DEFAULT_EDGES
},
SetViewModeShaded: {
name: 'Solid',
description: 'Set view mode to Solid',
modifiers: [ModifierKeys.Shift],
key: 'Digit3',
action: 'SetViewModeShaded',
viewMode: ViewMode.SHADED
},
SetViewModePen: {
name: 'Pen',
description: 'Set view mode to Pen',
modifiers: [ModifierKeys.Shift],
key: 'Digit4',
action: 'SetViewModePen',
viewMode: ViewMode.PEN
},
SetViewModeArctic: {
name: 'Arctic',
description: 'Set view mode to Arctic',
modifiers: [ModifierKeys.Shift],
key: 'Digit5',
action: 'SetViewModeArctic',
viewMode: ViewMode.ARCTIC
},
SetViewModeColors: {
name: 'Shaded',
description: 'Set view mode to Shaded',
modifiers: [ModifierKeys.Shift],
key: 'Digit6',
action: 'SetViewModeColors',
viewMode: ViewMode.COLORS
}
} as const
export const ViewShortcuts = {
SetViewTop: {
name: 'Top',
description: 'Set view to Top',
modifiers: [ModifierKeys.AltOrOpt],
key: 'Digit1',
action: 'SetViewTop'
},
SetViewFront: {
name: 'Front',
description: 'Set view to Front',
modifiers: [ModifierKeys.AltOrOpt],
key: 'Digit2',
action: 'SetViewFront'
},
SetViewLeft: {
name: 'Left',
description: 'Set view to Left',
modifiers: [ModifierKeys.AltOrOpt],
key: 'Digit3',
action: 'SetViewLeft'
},
SetViewBack: {
name: 'Back',
description: 'Set view to Back',
modifiers: [ModifierKeys.AltOrOpt],
key: 'Digit4',
action: 'SetViewBack'
},
SetViewRight: {
name: 'Right',
description: 'Set view to Right',
modifiers: [ModifierKeys.AltOrOpt],
key: 'Digit5',
action: 'SetViewRight'
}
} as const
export const ViewerShortcuts = {
...ViewModeShortcuts,
...PanelShortcuts,
...ToolShortcuts,
...ViewShortcuts
} as const
@@ -0,0 +1,18 @@
import type { ModifierKeys } from '@speckle/ui-components'
import type { ViewMode } from '@speckle/viewer'
import type { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
export type BaseShortcut = {
name: string
description: string
modifiers: readonly ModifierKeys[]
key: string
action: string
}
export type ViewModeShortcut = BaseShortcut & {
viewMode: ViewMode
}
export type ViewerShortcut = (typeof ViewerShortcuts)[keyof typeof ViewerShortcuts]
export type ViewerShortcutAction = keyof typeof ViewerShortcuts
+3 -2
View File
@@ -399,12 +399,13 @@ export async function init() {
const app = express()
app.disable('x-powered-by')
Logging(app)
// Moves things along automatically on restart.
// Should perhaps be done manually?
await migrateDbToLatest({ region: 'main', db: knex })
// Logging relies on 'regions' table in the database, so much be initialized after migrations
await Logging(app)
app.use(cookieParser())
app.use(DetermineRequestIdMiddleware)
app.use(determineClientIpAddressMiddleware)
@@ -14,7 +14,9 @@ type MetricConfig = {
prefix?: string
labels?: Record<string, string>
buckets?: Record<string, number[]>
knex: Knex
getDbClients: () => Promise<
Array<{ client: Knex; isMain: boolean; regionKey: string }>
>
}
type HighFrequencyMonitor = {
@@ -31,16 +31,17 @@ type MetricConfig = {
prefix?: string
labels?: Record<string, string>
buckets?: Record<BucketName, number[]>
knex: Knex
getDbClients: () => Promise<
Array<{ client: Knex; isMain: boolean; regionKey: string }>
>
}
export const knexConnections = (registry: Registry, config: MetricConfig): Metric => {
const registers = registry ? [registry] : undefined
const namePrefix = config.prefix ?? ''
const labels = config.labels ?? {}
const labelNames = Object.keys(labels)
const labelNames = [...Object.keys(labels), 'region']
const buckets = { ...DEFAULT_KNEX_TOTAL_BUCKETS, ...config.buckets }
const knex = config.knex
const knexConnectionsFree = new Histogram({
name: namePrefix + KNEX_CONNECTIONS_FREE,
@@ -91,15 +92,24 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
})
return {
collect: () => {
const connPool = knex.client.pool
collect: async () => {
for (const dbClient of await config.getDbClients()) {
const labelsAndRegion = { ...labels, region: dbClient.regionKey }
const connPool = dbClient.client.client.pool
knexConnectionsFree.observe(labels, connPool.numFree())
knexConnectionsUsed.observe(labels, connPool.numUsed())
knexPendingAcquires.observe(labels, connPool.numPendingAcquires())
knexPendingCreates.observe(labels, connPool.numPendingCreates())
knexPendingValidations.observe(labels, connPool.numPendingValidations())
knexRemainingCapacity.observe(labels, numberOfFreeConnections(knex))
knexConnectionsFree.observe(labelsAndRegion, connPool.numFree())
knexConnectionsUsed.observe(labelsAndRegion, connPool.numUsed())
knexPendingAcquires.observe(labelsAndRegion, connPool.numPendingAcquires())
knexPendingCreates.observe(labelsAndRegion, connPool.numPendingCreates())
knexPendingValidations.observe(
labelsAndRegion,
connPool.numPendingValidations()
)
knexRemainingCapacity.observe(
labelsAndRegion,
numberOfFreeConnections(dbClient.client)
)
}
}
}
}
+5 -5
View File
@@ -4,14 +4,14 @@ import promBundle from 'express-prom-bundle'
import { initKnexPrometheusMetrics } from '@/logging/knexMonitoring'
import { initHighFrequencyMonitoring } from '@/logging/highFrequencyMetrics/highfrequencyMonitoring'
import knex from '@/db/knex'
import { highFrequencyMetricsCollectionPeriodMs } from '@/modules/shared/helpers/envHelper'
import { startupLogger as logger } from '@/logging/logging'
import type express from 'express'
import { getAllRegisteredDbClients } from '@/modules/multiregion/utils/dbSelector'
let prometheusInitialized = false
export default function (app: express.Express) {
export default async function (app: express.Express) {
if (!prometheusInitialized) {
prometheusInitialized = true
prometheusClient.register.clear()
@@ -24,14 +24,14 @@ export default function (app: express.Express) {
register: prometheusClient.register,
collectionPeriodMilliseconds: highFrequencyMetricsCollectionPeriodMs(),
config: {
knex
getDbClients: getAllRegisteredDbClients
}
})
highfrequencyMonitoring.start()
initKnexPrometheusMetrics({
await initKnexPrometheusMetrics({
register: prometheusClient.register,
db: knex,
getAllDbClients: getAllRegisteredDbClients,
logger
})
const expressMetricsMiddleware = promBundle({
+207 -124
View File
@@ -5,150 +5,228 @@ import { Logger } from 'pino'
import { toNDecimalPlaces } from '@/modules/core/utils/formatting'
import { omit } from 'lodash'
export const initKnexPrometheusMetrics = (params: {
db: Knex
let metricQueryDuration: prometheusClient.Summary<string>
let metricQueryErrors: prometheusClient.Counter<string>
let metricConnectionAcquisitionDuration: prometheusClient.Histogram<string>
let metricConnectionPoolErrors: prometheusClient.Counter<string>
let metricConnectionInUseDuration: prometheusClient.Histogram<string>
let metricConnectionPoolReapingDuration: prometheusClient.Histogram<string>
const initializedRegions: string[] = []
let initializedPollingMetrics = false
export const initKnexPrometheusMetrics = async (params: {
getAllDbClients: () => Promise<
Array<{ client: Knex; isMain: boolean; regionKey: string }>
>
register: Registry
logger: Logger
}) => {
const normalizeSqlMethod = (sqlMethod: string) => {
if (!sqlMethod) return 'unknown'
switch (sqlMethod.toLocaleLowerCase()) {
case 'first':
return 'select'
default:
return sqlMethod.toLocaleLowerCase()
}
if (!initializedPollingMetrics) {
initializedPollingMetrics = true
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_free',
labelNames: ['region'],
help: 'Number of free DB connections',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
dbClient.client.client.pool.numFree()
)
}
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_used',
labelNames: ['region'],
help: 'Number of used DB connections',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
dbClient.client.client.pool.numUsed()
)
}
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending',
labelNames: ['region'],
help: 'Number of pending DB connection aquires',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
dbClient.client.client.pool.numPendingAcquires()
)
}
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending_creates',
labelNames: ['region'],
help: 'Number of pending DB connection creates',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
dbClient.client.client.pool.numPendingCreates()
)
}
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending_validations',
labelNames: ['region'],
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
dbClient.client.client.pool.numPendingValidations()
)
}
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_remaining_capacity',
labelNames: ['region'],
help: 'Remaining capacity of the DB connection pool',
async collect() {
for (const dbClient of await params.getAllDbClients()) {
this.set(
{ region: dbClient.regionKey },
numberOfFreeConnections(dbClient.client)
)
}
}
})
metricQueryDuration = new prometheusClient.Summary({
registers: [params.register],
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryErrors = new prometheusClient.Counter({
registers: [params.register],
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
metricConnectionAcquisitionDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_acquisition_duration',
labelNames: ['region'],
help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds'
})
metricConnectionPoolErrors = new prometheusClient.Counter({
registers: [params.register],
name: 'speckle_server_knex_connection_acquisition_errors',
labelNames: ['region'],
help: 'Number of DB connection pool acquisition errors'
})
metricConnectionInUseDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_usage_duration',
labelNames: ['region'],
help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds'
})
metricConnectionPoolReapingDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_pool_reaping_duration',
labelNames: ['region'],
help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.'
})
}
// configure hooks on knex
for (const dbClient of await params.getAllDbClients()) {
if (initializedRegions.includes(dbClient.regionKey)) continue
initKnexPrometheusMetricsForRegionEvents({
logger: params.logger,
region: dbClient.regionKey,
db: dbClient.client
})
initializedRegions.push(dbClient.regionKey)
}
}
const normalizeSqlMethod = (sqlMethod: string) => {
if (!sqlMethod) return 'unknown'
switch (sqlMethod.toLocaleLowerCase()) {
case 'first':
return 'select'
default:
return sqlMethod.toLocaleLowerCase()
}
}
interface QueryEvent extends Knex.Sql {
__knexUid: string
__knexTxId: string
__knexQueryUid: string
}
const initKnexPrometheusMetricsForRegionEvents = async (params: {
region: string
db: Knex
logger: Logger
}) => {
const { region, db } = params
const queryStartTime: Record<string, number> = {}
const connectionAcquisitionStartTime: Record<string, number> = {}
const connectionInUseStartTime: Record<string, number> = {}
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(params.db.client.pool.numFree())
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(params.db.client.pool.numUsed())
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(params.db.client.pool.numPendingAcquires())
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(params.db.client.pool.numPendingCreates())
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_pending_validations',
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
collect() {
this.set(params.db.client.pool.numPendingValidations())
}
})
new prometheusClient.Gauge({
registers: [params.register],
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
this.set(numberOfFreeConnections(params.db))
}
})
const metricQueryDuration = new prometheusClient.Summary({
registers: [params.register],
labelNames: ['sqlMethod', 'sqlNumberBindings'],
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
const metricQueryErrors = new prometheusClient.Counter({
registers: [params.register],
labelNames: ['sqlMethod', 'sqlNumberBindings'],
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
const metricConnectionAcquisitionDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_acquisition_duration',
help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds'
})
const metricConnectionPoolErrors = new prometheusClient.Counter({
registers: [params.register],
name: 'speckle_server_knex_connection_acquisition_errors',
help: 'Number of DB connection pool acquisition errors'
})
const metricConnectionInUseDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_usage_duration',
help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds'
})
const metricConnectionPoolReapingDuration = new prometheusClient.Histogram({
registers: [params.register],
name: 'speckle_server_knex_connection_pool_reaping_duration',
help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.'
})
// configure hooks on knex
params.db.on('query', (data) => {
db.on('query', (data: QueryEvent) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = performance.now()
})
params.db.on('query-response', (_data, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
db.on('query-response', (_response: unknown, data: QueryEvent) => {
const queryId = data.__knexQueryUid + ''
const durationMs = performance.now() - queryStartTime[queryId]
const durationSec = toNDecimalPlaces(durationMs / 1000, 2)
delete queryStartTime[queryId]
if (!isNaN(durationSec))
metricQueryDuration
.labels({
sqlMethod: normalizeSqlMethod(querySpec.method),
sqlNumberBindings: querySpec.bindings?.length || -1
region,
sqlMethod: normalizeSqlMethod(data.method),
sqlNumberBindings: data.bindings?.length || -1
})
.observe(durationSec)
params.logger.debug(
{
sql: querySpec.sql,
sqlMethod: normalizeSqlMethod(querySpec.method),
region,
sql: data.sql,
sqlMethod: normalizeSqlMethod(data.method),
sqlQueryId: queryId,
sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0),
sqlNumberBindings: querySpec.bindings?.length || -1
sqlNumberBindings: data.bindings?.length || -1
},
"DB query successfully completed, for method '{sqlMethod}', after {sqlQueryDurationMs}ms"
)
})
params.db.on('query-error', (err, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
db.on('query-error', (err: unknown, data: QueryEvent) => {
const queryId = data.__knexQueryUid + ''
const durationMs = performance.now() - queryStartTime[queryId]
const durationSec = toNDecimalPlaces(durationMs / 1000, 2)
delete queryStartTime[queryId]
@@ -156,25 +234,27 @@ export const initKnexPrometheusMetrics = (params: {
if (!isNaN(durationSec))
metricQueryDuration
.labels({
sqlMethod: normalizeSqlMethod(querySpec.method),
sqlNumberBindings: querySpec.bindings?.length || -1
region,
sqlMethod: normalizeSqlMethod(data.method),
sqlNumberBindings: data.bindings?.length || -1
})
.observe(durationSec)
metricQueryErrors.inc()
params.logger.warn(
{
err: omit(err, 'detail'),
sql: querySpec.sql,
sqlMethod: normalizeSqlMethod(querySpec.method),
err: typeof err === 'object' ? omit(err, 'detail') : err,
region,
sql: data.sql,
sqlMethod: normalizeSqlMethod(data.method),
sqlQueryId: queryId,
sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0),
sqlNumberBindings: querySpec.bindings?.length || -1
sqlNumberBindings: data.bindings?.length || -1
},
'DB query errored for {sqlMethod} after {sqlQueryDurationMs}ms'
)
})
const pool = params.db.client.pool
const pool = db.client.pool
// configure hooks on knex connection pool
pool.on('acquireRequest', (eventId: number) => {
@@ -190,7 +270,8 @@ export const initKnexPrometheusMetrics = (params: {
const now = performance.now()
const durationMs = now - connectionAcquisitionStartTime[eventId]
delete connectionAcquisitionStartTime[eventId]
if (!isNaN(durationMs)) metricConnectionAcquisitionDuration.observe(durationMs)
if (!isNaN(durationMs))
metricConnectionAcquisitionDuration.labels({ region }).observe(durationMs)
// successful acquisition is the start of usage, so record that start time
let knexUid: string | undefined = undefined
@@ -234,7 +315,8 @@ export const initKnexPrometheusMetrics = (params: {
const now = performance.now()
const durationMs = now - connectionInUseStartTime[knexUid]
if (!isNaN(durationMs)) metricConnectionInUseDuration.observe(durationMs)
if (!isNaN(durationMs))
metricConnectionInUseDuration.labels({ region }).observe(durationMs)
// params.logger.debug(
// {
// knexUid,
@@ -263,7 +345,8 @@ export const initKnexPrometheusMetrics = (params: {
pool.on('stopReaping', () => {
if (!reapingStartTime) return
const durationMs = performance.now() - reapingStartTime
if (!isNaN(durationMs)) metricConnectionPoolReapingDuration.observe(durationMs)
if (!isNaN(durationMs))
metricConnectionPoolReapingDuration.labels({ region }).observe(durationMs)
reapingStartTime = undefined
})
@@ -156,6 +156,7 @@ export const convertLegacyDataToStateFactory =
isOrthoProjection: !!data.camPos?.[6],
zoom: data.camPos?.[7] || 1
},
viewMode: 0,
sectionBox: sectionBox
? {
min: (sectionBox.min as number[]) || [0, 0, 0],
+1 -1
View File
@@ -57,7 +57,7 @@ export function buildNotificationsStateTracker() {
* Wait for an acknowledgement of a specific msg
*/
waitForMsgAck: async (msgId: JobId, timeout = 2000) => {
let timeoutRef: NodeJS.Timer
let timeoutRef: NodeJS.Timeout
let eventEmitterHandler: (e: AckEvent) => void
return new Promise<AckEvent>((resolve, reject) => {
// Set ack cb for notifications event handler
@@ -74,6 +74,7 @@ export type SerializedViewerState = {
isOrthoProjection: boolean
zoom: number
}
viewMode: number
sectionBox: Nullable<{
min: number[]
max: number[]
@@ -198,6 +199,7 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
isOrthoProjection: state.ui?.camera?.isOrthoProjection || false,
zoom: state.ui?.camera?.zoom || 1
},
viewMode: state.ui?.viewMode || 0,
sectionBox:
state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length
? {
@@ -127,7 +127,7 @@
ref="searchInput"
v-model="searchValue"
type="text"
class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px]"
class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px] focus-visible:[box-shadow:none] rounded-md hover:border-outline-5 focus-visible:border-outline-4"
:placeholder="searchPlaceholder"
@keydown.stop
/>
@@ -7,7 +7,7 @@ import { ToastNotificationType } from '~~/src/helpers/global/toast'
import type { ToastNotification } from '~~/src/helpers/global/toast'
import { useGlobalToast } from '~~/src/stories/composables/toast'
type StoryType = StoryObj<{ notification: ToastNotification }>
type StoryType = StoryObj<{ notifications: ToastNotification[] }>
export default {
component: ToastRenderer,
@@ -20,12 +20,11 @@ export default {
}
},
argTypes: {
notification: {
description: 'ToastNotification type object, nullable'
notifications: {
description: 'ToastNotification array, nullable'
},
'update:notification': {
description:
"Notification prop update event. Enables two-way binding through 'v-model:notification'"
dismiss: {
description: 'Dismiss event for a notification'
}
}
} as Meta
@@ -35,11 +34,11 @@ export const Default: StoryType = {
components: { ToastRenderer, FormButton },
setup() {
const { triggerNotification } = useGlobalToast()
const notification = ref(null as Nullable<ToastNotification>)
const notifications = ref(null as Nullable<ToastNotification[]>)
const onClick = () => {
triggerNotification(args.notification)
triggerNotification(args.notifications[0])
}
return { args, onClick, notification }
return { args, onClick, notifications }
},
template: `
<div>
@@ -50,30 +49,34 @@ export const Default: StoryType = {
parameters: {
docs: {
source: {
code: '<GlobalToastRenderer v-model:notification="notification"/>'
code: '<GlobalToastRenderer v-model:notifications="notifications" />'
}
}
},
args: {
notification: {
type: ToastNotificationType.Info,
title: 'Title',
description: 'Description',
notifications: [
{
type: ToastNotificationType.Info,
title: 'Title',
description: 'Description',
cta: {
title: 'CTA'
cta: {
title: 'CTA'
}
}
}
]
}
}
export const WithManualClose: StoryType = {
...Default,
args: {
notification: {
...Default.args!.notification!,
autoClose: false
}
notifications: [
{
...Default.args!.notifications![0],
autoClose: false
}
]
}
}
@@ -81,23 +84,20 @@ export const NoCtaOrDescription: StoryObj = {
render: (args) => ({
components: { ToastRenderer, FormButton },
setup() {
const notification = ref(null as Nullable<ToastNotification>)
const { triggerNotification } = useGlobalToast()
const notifications = ref(null as Nullable<ToastNotification[]>)
const onClick = () => {
// Update notification without cta or description
notification.value = {
triggerNotification({
type: ToastNotificationType.Info,
title: 'Displays a toast notification'
}
// Clear after 2s
setTimeout(() => (notification.value = null), 2000)
})
}
return { args, onClick, notification }
return { args, onClick, notifications }
},
template: `
<div>
<FormButton @click="onClick">Trigger Title Only</FormButton>
<ToastRenderer v-model:notification="notification"/>
<ToastRenderer v-model:notifications="notifications" />
</div>
`
}),
@@ -108,8 +108,8 @@ export const NoCtaOrDescription: StoryObj = {
},
source: {
code: `
<FormButton @click="onClick">Trigger Title Only</FormButton>
<ToastRenderer v-model:notification="notification"/>
<FormButton @click="onClick">Trigger Title Only</FormButton>
<ToastRenderer v-model:notifications="notifications" />
`
}
}
@@ -5,7 +5,7 @@
>
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
<Transition
<TransitionGroup
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
@@ -14,9 +14,10 @@
leave-to-class="opacity-0"
>
<div
v-if="notification"
v-for="(notification, index) in notifications"
:key="`toast-${index}`"
class="pointer-events-auto w-full max-w-[20rem] overflow-hidden rounded bg-foundation text-foreground shadow-lg border border-outline-2 p-3"
:class="{ 'pb-2': isTitleOnly }"
:class="{ 'pb-2': !notification?.description && !notification?.cta }"
>
<div class="flex space-x-2">
<div class="flex-shrink-0 mt-1">
@@ -60,7 +61,7 @@
color="subtle"
:to="notification.cta.url"
size="sm"
@click="onCtaClick"
@click="(e: MouseEvent) => onCtaClick(notification, e)"
>
{{ notification.cta.title }}
</FormButton>
@@ -70,7 +71,7 @@
<button
type="button"
class="inline-flex rounded-md bg-foundation text-foreground-2 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
@click="dismiss"
@click="dismiss(notification)"
>
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
@@ -78,7 +79,7 @@
</div>
</div>
</div>
</Transition>
</TransitionGroup>
</div>
</div>
</template>
@@ -91,29 +92,24 @@ import {
InformationCircleIcon,
XMarkIcon
} from '@heroicons/vue/20/solid'
import { computed } from 'vue'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { ToastNotificationType } from '~~/src/helpers/global/toast'
import type { ToastNotification } from '~~/src/helpers/global/toast'
const emit = defineEmits<{
(e: 'update:notification', val: MaybeNullOrUndefined<ToastNotification>): void
(e: 'dismiss', val: ToastNotification): void
}>()
const props = defineProps<{
notification: MaybeNullOrUndefined<ToastNotification>
defineProps<{
notifications: MaybeNullOrUndefined<ToastNotification[]>
}>()
const isTitleOnly = computed(
() => !props.notification?.description && !props.notification?.cta
)
const dismiss = () => {
emit('update:notification', null)
const dismiss = (notification: ToastNotification) => {
emit('dismiss', notification)
}
const onCtaClick = (e: MouseEvent) => {
props.notification?.cta?.onClick?.(e)
dismiss()
const onCtaClick = (notification: ToastNotification, e: MouseEvent) => {
notification.cta?.onClick?.(e)
dismiss(notification)
}
</script>
@@ -9,14 +9,14 @@ export enum ModifierKeys {
export const clientOs = getClientOperatingSystem()
export const ModifierKeyTitles: Record<ModifierKeys, string> = {
[ModifierKeys.CtrlOrCmd]: clientOs === OperatingSystem.Mac ? 'Cmd' : 'Ctrl',
[ModifierKeys.AltOrOpt]: clientOs === OperatingSystem.Mac ? 'Opt' : 'Alt',
[ModifierKeys.Shift]: 'Shift'
[ModifierKeys.CtrlOrCmd]: clientOs === OperatingSystem.Mac ? '' : '',
[ModifierKeys.AltOrOpt]: clientOs === OperatingSystem.Mac ? '' : 'Alt',
[ModifierKeys.Shift]: ''
}
export function getKeyboardShortcutTitle(keys: Array<string | ModifierKeys>) {
const isModifierKey = (k: string): k is ModifierKeys =>
(Object.values(ModifierKeys) as string[]).includes(k)
return keys.map((v) => (isModifierKey(v) ? ModifierKeyTitles[v] : v)).join('+')
return keys.map((v) => (isModifierKey(v) ? ModifierKeyTitles[v] : v)).join('')
}
@@ -25,4 +25,5 @@ export type ToastNotification = {
* Defaults to true
*/
autoClose?: boolean
id?: string
}
@@ -1,18 +1,18 @@
<template>
<ToastRenderer v-model:notification="notification" />
<ToastRenderer v-model:notifications="notifications" @dismiss="dismiss" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ToastRenderer from '~~/src/components/global/ToastRenderer.vue'
import { useGlobalToastManager } from '~~/src/stories/composables/toast'
const { currentNotification, dismiss } = useGlobalToastManager()
const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager()
const notification = computed({
get: () => currentNotification.value,
const notifications = computed({
get: () => currentNotifications.value,
set: (newVal) => {
if (!newVal) {
dismiss()
dismissAll()
}
}
})
@@ -1,57 +1,94 @@
import { useTimeoutFn, createGlobalState } from '@vueuse/core'
import type { Nullable } from '@speckle/shared'
import { computed, nextTick, watch } from 'vue'
import { ref } from 'vue'
import type { ToastNotification } from '~~/src/helpers/global/toast'
import type { Optional } from '@speckle/shared'
import { useTimeoutFn, createGlobalState } from '@vueuse/core'
import { computed, watch } from 'vue'
import { ref } from 'vue'
import { nanoid } from 'nanoid'
/**
* Development-only version of the toast notification state. Do not export this out from the library as it can't work in SSR!
*/
const useGlobalToastState = createGlobalState(() =>
ref(null as Nullable<ToastNotification>)
ref([] as Optional<ToastNotification[]>)
)
/**
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
*/
export function useGlobalToastManager() {
type Timeout = {
id: string
stop: () => void
}
const stateNotification = useGlobalToastState()
const currentNotification = ref(stateNotification.value)
const readOnlyNotification = computed(() => currentNotification.value)
const timeouts = ref<Timeout[]>([])
const currentNotifications = ref<ToastNotification[]>(
Array.isArray(stateNotification.value) ? stateNotification.value : []
)
const readOnlyNotification = computed(() => currentNotifications.value)
const { start, stop } = useTimeoutFn(() => {
dismiss()
}, 4000)
// Remove a specific notification from the state
const removeNotification = (id: string) => {
const index = currentNotifications.value.findIndex((n) => n.id === id)
if (index !== -1) {
currentNotifications.value.splice(index, 1)
// Clean up timeout
timeouts.value = timeouts.value.filter((t) => t.id !== id)
}
}
// Create a timeout for a notification
const createTimeout = (notification: ToastNotification) => {
const { stop } = useTimeoutFn(() => {
if (notification.id) {
removeNotification(notification.id)
}
}, 4000)
return stop
}
watch(
stateNotification,
async (newVal) => {
(newVal) => {
if (!newVal) return
currentNotifications.value = newVal
// First dismiss old notification, then set a new one on next tick
// this is so that the old one actually disappears from the screen for the user,
// instead of just having its contents replaced
dismiss()
// Create timeout for the new notification
const index = currentNotifications.value.length - 1
const lastNotification = newVal[index]
await nextTick(() => {
currentNotification.value = newVal
// (re-)init timeout
stop()
if (newVal.autoClose !== false) start()
})
if (lastNotification && !lastNotification.autoClose) {
timeouts.value.push({
id: lastNotification.id as string,
stop: createTimeout(lastNotification)
})
}
},
{ deep: true }
{ deep: true, immediate: true }
)
const dismiss = () => {
currentNotification.value = null
stateNotification.value = null
// Function to dismiss a specific notification
const dismiss = (notification: ToastNotification) => {
if (!notification.id) return
const targetTimeout = timeouts.value.find((t) => t.id === notification.id)
if (targetTimeout) {
targetTimeout.stop()
}
removeNotification(notification.id)
}
return { currentNotification: readOnlyNotification, dismiss }
// Dismiss all notifications
const dismissAll = () => {
timeouts.value.forEach((timeout) => timeout.stop())
timeouts.value = []
currentNotifications.value = []
}
return { currentNotifications: readOnlyNotification, dismiss, dismissAll }
}
/**
@@ -64,7 +101,11 @@ export function useGlobalToast() {
* Trigger a new toast notification
*/
const triggerNotification = (notification: ToastNotification) => {
stateNotification.value = notification
const newNotification = { ...notification, id: nanoid() }
stateNotification.value
? stateNotification.value.push(newNotification)
: (stateNotification.value = [newNotification])
}
return { triggerNotification }
@@ -138,6 +138,6 @@ export class ExtendedSelection extends SelectionExtension {
)
}
this.lastGizmoTranslation.copy(this.dummyAnchor.position)
this.viewer.requestRender(UpdateFlags.RENDER | UpdateFlags.SHADOWS)
this.viewer.requestRender(UpdateFlags.RENDER_RESET | UpdateFlags.SHADOWS)
}
}
@@ -31,6 +31,9 @@ export class ViewModesKeys extends Extension {
case '5':
viewModes.setViewMode(ViewMode.ARCTIC)
break
case '6':
viewModes.setViewMode(ViewMode.COLORS)
break
}
})
}
+10 -3
View File
@@ -11,7 +11,6 @@ import {
import './style.css'
import Sandbox from './Sandbox'
import {
SelectionExtension,
MeasurementsExtension,
ExplodeExtension,
DiffExtension,
@@ -21,6 +20,7 @@ import { SectionTool } from '@speckle/viewer'
import { SectionOutlines } from '@speckle/viewer'
import { ViewModesKeys } from './Extensions/ViewModesKeys'
import { BoxSelection } from './Extensions/BoxSelection'
import { ExtendedSelection } from './Extensions/ExtendedSelection'
const createViewer = async (containerName: string, stream: string) => {
const container = document.querySelector<HTMLElement>(containerName)
@@ -45,7 +45,8 @@ const createViewer = async (containerName: string, stream: string) => {
await viewer.init()
const cameraController = viewer.createExtension(CameraController)
const selection = viewer.createExtension(SelectionExtension)
const selection = viewer.createExtension(ExtendedSelection)
selection.init()
const sections = viewer.createExtension(SectionTool)
viewer.createExtension(SectionOutlines)
const measurements = viewer.createExtension(MeasurementsExtension)
@@ -115,7 +116,7 @@ const getStream = () => {
// 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8'
// 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d'
// 'Super' heavy revit shit
'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5'
// 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5'
// IFC building (good for a tree based structure)
// 'https://latest.speckle.systems/streams/92b620fb17/commits/2ebd336223'
// IFC story, a subtree of the above
@@ -454,6 +455,12 @@ const getStream = () => {
// 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e'
// 'https://speckle.xyz/streams/27e89d0ad6/commits/5ed4b74252'
// DUI3 Mesh Colors
'https://app.speckle.systems/projects/93200a735d/models/cbacd3eaeb@344a397239'
// Instance toilets
// 'https://app.speckle.systems/projects/e89b61b65c/models/2a0995f124'
)
}
+7 -2
View File
@@ -131,6 +131,8 @@ import {
} from './modules/materials/Materials.js'
import { AccelerationStructure } from './modules/objects/AccelerationStructure.js'
import { TopLevelAccelerationStructure } from './modules/objects/TopLevelAccelerationStructure.js'
import { ViewModeEvent, ViewModeEventPayload } from './modules/extensions/ViewModes.js'
import { BasitPipeline } from './modules/pipeline/Pipelines/BasitViewPipeline.js'
export {
Viewer,
@@ -214,6 +216,7 @@ export {
MRTEdgesPipeline,
MRTShadedViewPipeline,
MRTPenViewPipeline,
BasitPipeline,
ViewModes,
ViewMode,
FilterMaterial,
@@ -221,7 +224,8 @@ export {
FilterMaterialOptions,
NOT_INTERSECTED,
INTERSECTED,
CONTAINED
CONTAINED,
ViewModeEvent
}
export type {
@@ -255,7 +259,8 @@ export type {
SectionToolEventPayload,
CameraEventPayload,
SelectionExtensionOptions,
DefaultSelectionExtensionOptions
DefaultSelectionExtensionOptions,
ViewModeEventPayload
}
export * as UrlHelper from './modules/UrlHelper.js'
@@ -1,5 +1,6 @@
import { UpdateFlags } from '../../IViewer.js'
import { ArcticViewPipeline } from '../pipeline/Pipelines/ArcticViewPipeline.js'
import { BasitPipeline } from '../pipeline/Pipelines/BasitViewPipeline.js'
import { DefaultPipeline } from '../pipeline/Pipelines/DefaultPipeline.js'
import { EdgesPipeline } from '../pipeline/Pipelines/EdgesPipeline.js'
import { MRTEdgesPipeline } from '../pipeline/Pipelines/MRT/MRTEdgesPipeline.js'
@@ -14,10 +15,26 @@ export enum ViewMode {
DEFAULT_EDGES,
SHADED,
PEN,
ARCTIC
ARCTIC,
COLORS
}
export enum ViewModeEvent {
Changed = 'view-mode-changed'
}
export interface ViewModeEventPayload {
[ViewModeEvent.Changed]: ViewMode
}
export class ViewModes extends Extension {
public on<T extends ViewModeEvent>(
eventType: T,
listener: (arg: ViewModeEventPayload[T]) => void
): void {
super.on(eventType, listener)
}
public setViewMode(viewMode: ViewMode) {
const renderer = this.viewer.getRenderer()
const isMRTCapable =
@@ -46,7 +63,12 @@ export class ViewModes extends Extension {
case ViewMode.ARCTIC:
renderer.pipeline = new ArcticViewPipeline(renderer)
break
case ViewMode.COLORS:
renderer.pipeline = new BasitPipeline(renderer, this.viewer.getWorldTree())
break
}
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
this.emit(ViewModeEvent.Changed, viewMode)
}
}
@@ -226,6 +226,10 @@ void main() {
vec4 rtePivotShadow = computeRelativePositionSeparate(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high);
shadowPosition.xyz = rotate_vertex_position((shadowPosition - rtePivotShadow).xyz, tQuaternion) * tScale.xyz + rtePivotShadow.xyz + tTranslation.xyz;
#endif
#ifdef USE_INSTANCING
vec4 rtePivotShadow = computeRelativePositionSeparate(ZERO3, ZERO3, uShadowViewer_low, uShadowViewer_high);
shadowPosition.xyz = (mat3(instanceMatrix) * (shadowPosition - rtePivotShadow).xyz) + rtePivotShadow.xyz + instanceMatrix[3].xyz;
#endif
shadowWorldPosition = modelMatrix * shadowPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );
vDirectionalShadowCoord[ i ] = shadowMatrix * shadowWorldPosition;
@@ -11,6 +11,7 @@ import {
Object3D,
Ray,
Raycaster,
RGBADepthPacking,
SkinnedMesh,
Sphere,
Triangle,
@@ -29,6 +30,7 @@ import {
} from '../batching/Batch.js'
import { SpeckleRaycaster } from './SpeckleRaycaster.js'
import Logger from '../utils/Logger.js'
import SpeckleDepthMaterial from '../materials/SpeckleDepthMaterial.js'
const _inverseMatrix = new Matrix4()
const _ray = new Ray()
@@ -183,6 +185,7 @@ export default class SpeckleInstancedMesh extends Group {
public updateDrawGroups(transformBuffer: Float32Array, gradientBuffer: Float32Array) {
this.instances.forEach((value: InstancedMesh) => {
this.remove(value)
value.customDepthMaterial?.dispose()
value.dispose()
})
this.instances.length = 0
@@ -218,6 +221,14 @@ export default class SpeckleInstancedMesh extends Group {
group.instanceMatrix.needsUpdate = true
group.layers.set(ObjectLayers.STREAM_CONTENT_MESH)
group.frustumCulled = false
group.customDepthMaterial = new SpeckleDepthMaterial(
{
depthPacking: RGBADepthPacking
},
['USE_RTE', 'ALPHATEST_REJECTION']
)
group.castShadow = !material.transparent
group.receiveShadow = !material.transparent
this.instances.push(group)
this.add(group)
@@ -32,13 +32,14 @@ export class BasitPass extends BaseGPass {
super()
this.tree = tree
this.speckleRenderer = renderer
this.buildMaterials()
}
public get displayName(): string {
return 'BASIT'
}
onBeforeRender = () => {
protected buildMaterials() {
const batches: MeshBatch[] = this.speckleRenderer.batcher.getBatches(
undefined,
GeometryType.MESH
@@ -112,14 +113,14 @@ export class BasitPass extends BaseGPass {
protected overrideMaterials() {
for (const k in this.materialMap) {
const tuple = this.materialMap[k]
;(tuple[0].renderObject as SpeckleMesh).setOverrideMaterial(tuple[2])
;(tuple[0].renderObject as SpeckleMesh).setOverrideBatchMaterial(tuple[2])
}
}
protected restoreMaterials() {
for (const k in this.materialMap) {
const tuple = this.materialMap[k]
;(tuple[0].renderObject as SpeckleMesh).restoreMaterial()
;(tuple[0].renderObject as SpeckleMesh).restoreBatchMaterial()
}
}
@@ -3,7 +3,9 @@ import SpeckleRenderer from '../../SpeckleRenderer.js'
import { GeometryPass } from '../Passes/GeometryPass.js'
import { Pipeline } from './Pipeline.js'
import { BasitPass } from '../Passes/BasitPass.js'
import { ClearFlags } from '../Passes/GPass.js'
import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js'
import { StencilPass } from '../Passes/StencilPass.js'
import { StencilMaskPass } from '../Passes/StencilMaskPass.js'
export class BasitPipeline extends Pipeline {
constructor(speckleRenderer: SpeckleRenderer, tree: WorldTree) {
@@ -12,13 +14,38 @@ export class BasitPipeline extends Pipeline {
const basitPass = new BasitPass(tree, speckleRenderer)
basitPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
basitPass.setClearColor(0x000000, 0)
basitPass.setClearFlags(ClearFlags.COLOR | ClearFlags.DEPTH | ClearFlags.STENCIL)
basitPass.setClearFlags(ClearFlags.COLOR)
basitPass.outputTarget = null
const transparentColorPass = new GeometryPass()
transparentColorPass.setLayers([ObjectLayers.SHADOWCATCHER])
transparentColorPass.outputTarget = null
const nonMeshPass = new GeometryPass()
nonMeshPass.setLayers([
ObjectLayers.STREAM_CONTENT_LINE,
ObjectLayers.STREAM_CONTENT_POINT,
ObjectLayers.STREAM_CONTENT_POINT_CLOUD,
ObjectLayers.STREAM_CONTENT_TEXT
])
const stencilPass = new StencilPass()
stencilPass.setVisibility(ObjectVisibility.STENCIL)
stencilPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
this.passList.push(basitPass, transparentColorPass)
const stencilMaskPass = new StencilMaskPass()
stencilMaskPass.setVisibility(ObjectVisibility.STENCIL)
stencilMaskPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
stencilMaskPass.setClearFlags(ClearFlags.DEPTH)
const overlayPass = new GeometryPass()
overlayPass.setLayers([
ObjectLayers.PROPS,
ObjectLayers.OVERLAY,
ObjectLayers.MEASUREMENTS
])
this.passList.push(
stencilPass,
basitPass,
nonMeshPass,
stencilMaskPass,
overlayPass
)
}
}