Merge branch 'main' into iain/dockerfile-node18-node22

This commit is contained in:
Iain Sproat
2025-02-05 12:16:04 +00:00
1291 changed files with 248631 additions and 201201 deletions
+102 -14
View File
@@ -16,16 +16,23 @@ workflows:
- main
- hotfix*
- test-server:
- test-server: &test-server-job-definition
context:
- speckle-server-licensing
- stripe-integration
filters: &filters-allow-all
tags:
# run tests for any commit on any branch, including any tags
only: /.*/
requires:
- docker-publish-postgres-container
- test-server-no-ff:
filters: *filters-allow-all
requires:
- docker-publish-postgres-container
- test-server-multiregion: *test-server-job-definition
- test-frontend-2:
filters: *filters-allow-all
@@ -145,6 +152,12 @@ workflows:
requires:
- get-version
- docker-build-postgres-container:
context: *build-context
filters: *filters-build
requires:
- get-version
- docker-build-monitor-container:
context: *build-context
filters: *filters-build
@@ -179,6 +192,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend:
@@ -194,6 +208,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-frontend-2:
@@ -209,6 +224,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-webhooks:
@@ -224,6 +240,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-file-imports:
@@ -239,6 +256,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-previews:
@@ -254,6 +272,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-test-container:
@@ -269,8 +288,15 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-postgres-container:
context: *docker-hub-context
filters: *filters-publish
requires:
- docker-build-postgres-container
- docker-publish-monitor-container:
context: *docker-hub-context
filters: *filters-publish
@@ -284,6 +310,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- docker-publish-docker-compose-ingress:
@@ -299,6 +326,7 @@ workflows:
- test-objectsender
- test-server
- test-server-no-ff
- test-server-multiregion
- test-preview-service
- publish-helm-chart:
@@ -339,6 +367,7 @@ workflows:
- get-version
- test-server
- test-server-no-ff
- test-server-multiregion
- test-ui-components
- test-frontend-2
- test-viewer
@@ -438,11 +467,12 @@ jobs:
docker:
- image: cimg/node:22.6.0
- image: cimg/redis:7.2.4
- image: cimg/postgres:14.11
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000'
- image: 'minio/minio'
command: server /data --console-address ":9001"
# environment:
@@ -452,6 +482,7 @@ jobs:
NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle
SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true'
@@ -463,7 +494,9 @@ jobs:
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
RATELIMITER_ENABLED: 'false'
steps:
- checkout
- restore_cache:
@@ -511,8 +544,8 @@ jobs:
working_directory: 'packages/server'
- run:
name: Checking for GQL schema breakages against speckle.xyz
command: 'yarn rover graph check Speckle-Server@speckle-xyz --schema ./introspected-schema.graphql'
name: Checking for GQL schema breakages against app.speckle.systems
command: 'yarn rover graph check Speckle-Server@app-speckle-systems --schema ./introspected-schema.graphql'
working_directory: 'packages/server'
- run:
@@ -544,12 +577,56 @@ jobs:
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_AUTOMATE_MODULE_ENABLED: 'false' # Disable all FFs
FF_WORKSPACES_MODULE_ENABLED: 'false'
FF_WORKSPACES_SSO_ENABLED: 'false'
FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false'
FF_GENDOAI_MODULE_ENABLED: 'false'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
DISABLE_ALL_FFS: 'true'
RATELIMITER_ENABLED: 'false'
test-server-multiregion:
<<: *test-server-job
docker:
- image: cimg/node:18.19.0
- image: cimg/redis:7.2.4
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000' -c 'wal_level=logical'
- image: 'speckle/speckle-postgres'
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
command: -c 'max_connections=1000' -c 'port=5433' -c 'wal_level=logical'
- image: 'minio/minio'
command: server /data --console-address ":9001" --address "0.0.0.0:9000"
- image: 'minio/minio'
command: server /data --console-address ":9021" --address "0.0.0.0:9020"
environment:
# Same as test-server:
NODE_ENV: test
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
PGDATABASE: speckle2_test
POSTGRES_MAX_CONNECTIONS_SERVER: 20
PGUSER: speckle
SESSION_SECRET: 'keyboard cat'
STRATEGY_LOCAL: 'true'
CANONICAL_URL: 'http://127.0.0.1:3000'
S3_ENDPOINT: 'http://127.0.0.1:9000'
S3_ACCESS_KEY: 'minioadmin'
S3_SECRET_KEY: 'minioadmin'
S3_BUCKET: 'speckle-server'
S3_CREATE_BUCKET: 'true'
REDIS_URL: 'redis://127.0.0.1:6379'
S3_REGION: '' # optional, defaults to 'us-east-1'
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
FF_BILLING_INTEGRATION_ENABLED: 'true'
# These are the only different env keys:
MULTI_REGION_CONFIG_PATH: '../../.circleci/multiregion.test-ci.json'
FF_WORKSPACES_MODULE_ENABLED: 'true'
FF_WORKSPACES_MULTI_REGION_ENABLED: 'true'
RUN_TESTS_IN_MULTIREGION_MODE: true
RATELIMITER_ENABLED: 'false'
test-frontend-2:
docker: &docker-node-browsers-image
@@ -618,7 +695,7 @@ jobs:
test-preview-service:
docker:
- image: cimg/node:22.6.0-browsers
- image: cimg/postgres:14.11
- image: cimg/postgres:16.4@sha256:2e4f1a965bdd9ba77aa6a0a7b93968c07576ba2a8a7cf86d5eb7b31483db1378
environment:
POSTGRES_DB: preview_service_test
POSTGRES_PASSWORD: preview_service_test
@@ -965,10 +1042,15 @@ jobs:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: test-deployment
docker-build-monitor-container:
docker-build-postgres-container:
<<: *build-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: postgres
docker-build-monitor-container:
<<: *build-job
environment:
SPECKLE_SERVER_PACKAGE: monitor-deployment
docker-build-docker-compose-ingress:
@@ -1029,10 +1111,15 @@ jobs:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: test-deployment
docker-publish-monitor-container:
docker-publish-postgres-container:
<<: *publish-job
environment:
FOLDER: utils
SPECKLE_SERVER_PACKAGE: postgres
docker-publish-monitor-container:
<<: *publish-job
environment:
SPECKLE_SERVER_PACKAGE: monitor-deployment
docker-publish-docker-compose-ingress:
@@ -1117,6 +1204,7 @@ jobs:
publish-viewer-sandbox-cloudflare-pages:
docker: *docker-node-image
working_directory: *work-dir
resource_class: large
steps:
- checkout
- restore_cache:
+2 -1
View File
@@ -45,11 +45,12 @@ k8s_yaml('./manifests/priorityclass.yaml')
k8s_yaml('./manifests/speckle-server.secret.yaml')
# Install charts
# Postgres 16.4 is packaged in chart 15.5.38
helm_resource('postgresql',
release_name='postgresql',
namespace='postgres',
chart='oci://registry-1.docker.io/bitnamicharts/postgresql',
flags=['--version=^12.0.0',
flags=['--version=^15.5.38',
'--values=./values/postgres.values.yaml',
'--kube-context=kind-speckle-server'],
deps=['./values/postgres.values.yaml'],
+30
View File
@@ -0,0 +1,30 @@
{
"main": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5432/speckle2_test"
},
"blobStorage": {
"accessKey": "minioadmin",
"secretKey": "minioadmin",
"bucket": "speckle-server",
"createBucketIfNotExists": true,
"endpoint": "http://127.0.0.1:9000",
"s3Region": "us-east-1"
}
},
"regions": {
"region1": {
"postgres": {
"connectionUri": "postgresql://speckle:speckle@127.0.0.1:5433/speckle2_test"
},
"blobStorage": {
"accessKey": "minioadmin",
"secretKey": "minioadmin",
"bucket": "speckle-server",
"createBucketIfNotExists": true,
"endpoint": "http://127.0.0.1:9020",
"s3Region": "us-east-1"
}
}
}
}
@@ -69,7 +69,7 @@ jobs:
services:
postgres:
# Docker Hub image
image: postgres:14
image: postgres:16.4-bookworm@sha256:e62fbf9d3e2b49816a32c400ed2dba83e3b361e6833e624024309c35d334b412
env:
POSTGRES_DB: preview_service_test
POSTGRES_PASSWORD: preview_service_test
+10 -1
View File
@@ -71,4 +71,13 @@ minio-data/
postgres-data/
redis-data/
.tshy-build
.tshy-build
obj/
bin/
!packages/monitor-deployment/bin
!packages/preview-service/bin
!packages/server/bin
# Server
multiregion.json
multiregion.test.json
+5 -1
View File
@@ -38,4 +38,8 @@ venv
storybook-static
.tshy
.tshy-build
.tshy-build
packages/fileimport-service/ifc-dotnet/
packages/fileimport-service/stl/
packages/fileimport-service/obj/
+16 -35
View File
@@ -2,12 +2,14 @@
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
Speckle | Server
</h1>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
> Speckle is the first AEC data hub that connects with your favorite AEC tools. Speckle exists to overcome the challenges of working in a fragmented industry where communication, creative workflows, and the exchange of data are often hindered by siloed software and processes. It is here to make the industry better.
<h3 align="center">
Server and Web packages
</h3>
<p align="center"><b>Speckle</b> is data infrastructure for the AEC industry.</p><br/>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center">
<a href="https://codecov.io/gh/specklesystems/speckle-server">
@@ -18,37 +20,6 @@
</a>
</p>
# About Speckle
What is Speckle? Check our [![YouTube Video Views](https://img.shields.io/youtube/views/B9humiSpHzM?label=Speckle%20in%201%20minute%20video&style=social)](https://www.youtube.com/watch?v=B9humiSpHzM)
## Features
- **Object-based:** say goodbye to files! Speckle is the first object based platform for the AEC industry
- **Version control:** Speckle is the Git & Hub for geometry and BIM data
- **Collaboration:** share your designs collaborate with others
- **3D Viewer:** see your CAD and BIM models online, share and embed them anywhere
- **Connectivity:** get your CAD and BIM models into other software without exporting or importing
- **Real time:** get real time updates and notifications and changes
- **GraphQL API:** get what you need anywhere you want it
- **Webhooks:** the base for a automation and next-gen pipelines
- **Built for developers:** we are building Speckle with developers in mind and have tools for every stack
- **Built for the AEC industry:** Speckle connectors are plugins for the most common software used in the industry such as Revit, Rhino, Grasshopper, AutoCAD, Civil 3D, Excel, Unreal Engine, Unity, QGIS, Blender, ArchiCAD and more!
## Try Speckle now!
Give Speckle a try in no time by:
- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ Create an account at app.speckle.systems
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-manualsetup.html)>) ⇒ Deploy on your own infrastructure with Docker Compose
- [![Deploy on your own infrastructure with docker compose](https://img.shields.io/badge/https://-speckle.guide-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](<[https://](https://speckle.guide/dev/server-setup-k8s.html)>) ⇒ Deploy on your own infrastructure with Kubernetes
## Resources
- [![Community forum users](https://img.shields.io/badge/community-forum-green?style=for-the-badge&logo=discourse&logoColor=white)](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
# Repo structure
This monorepo is the home of the Speckle v2 web packages:
@@ -65,8 +36,10 @@ This monorepo is the home of the Speckle v2 web packages:
Make sure to also check and ⭐️ these other Speckle repositories:
- [`speckle-sharp`](https://github.com/specklesystems/speckle-sharp): .NET tooling, connectors and interoperability
- [`speckle-sharp-connectors`](https://github.com/specklesystems/speckle-sharp-connectors): .NET connectors and desktop UI
- [`speckle-sharp-sdk`](https://github.com/specklesystems/speckle-sharp-sdk): .NET SDK, tests, and Objects
- [`specklepy`](https://github.com/specklesystems/specklepy): Python SDK 🐍
- [`speckle-sketchup`](https://github.com/specklesystems/speckle-sketchup): Sketchup connector
- [`speckle-excel`](https://github.com/specklesystems/speckle-excel): Excel connector
- [`speckle-unity`](https://github.com/specklesystems/speckle-unity): Unity 3D connector
- [`speckle-blender`](https://github.com/specklesystems/speckle-blender): Blender connector
@@ -126,6 +99,14 @@ EMAIL_PORT="1025"
The web portal is available at `localhost:1080` and it's listening for mail on port `1025`.
### Minio (S3 storage)
Default credentials are: `minioadmin:minioadmin`
Main storage Web UI: [http://localhost:9001/](http://localhost:9001/)
Region1 storage Web UI: [http://localhost:9021/](http://localhost:9021/)
You can use the web UI to validate uploaded blobs
# Contributing
Please make sure you read the [contribution guidelines](https://github.com/specklesystems/speckle-server/blob/main/CONTRIBUTING.md) for an overview of the best practices we try to follow.
+29 -1
View File
@@ -5,7 +5,7 @@ services:
postgres:
build:
context: .
dockerfile: docker/postgres/Dockerfile
dockerfile: utils/postgres/Dockerfile
restart: always
environment:
POSTGRES_DB: speckle
@@ -18,6 +18,22 @@ services:
ports:
- '127.0.0.1:5432:5432'
postgres-region1:
build:
context: .
dockerfile: utils/postgres/Dockerfile
restart: always
environment:
POSTGRES_DB: speckle
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- postgres-region1-data:/var/lib/postgresql/data/
- ./setup/db/10-docker_postgres_init.sql:/docker-entrypoint-initdb.d/10-docker_postgres_init.sql
- ./setup/db/11-docker_postgres_keycloack_init.sql:/docker-entrypoint-initdb.d/11-docker_postgres_keycloack_init.sql
ports:
- '127.0.0.1:5401:5432'
redis:
image: 'redis:7-alpine'
restart: always
@@ -36,6 +52,16 @@ services:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
minio-region1:
image: 'minio/minio'
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-region1-data:/data
ports:
- '127.0.0.1:9020:9000'
- '127.0.0.1:9021:9001'
# Local OIDC provider for testing
keycloak:
image: quay.io/keycloak/keycloak:25.0
@@ -106,7 +132,9 @@ services:
volumes:
postgres-data:
postgres-region1-data:
redis-data:
pgadmin-data:
redis_insight-data:
minio-data:
minio-region1-data:
-25
View File
@@ -1,25 +0,0 @@
FROM postgres:14.5-alpine AS builder
RUN apk add --no-cache 'git=~2.36' \
'build-base=~0.5' \
'clang=~13.0' \
'llvm13=~13.0'
WORKDIR /
RUN git clone --branch 1.1.9 https://github.com/aiven/aiven-extras.git aiven-extras
WORKDIR /aiven-extras
RUN git checkout 36598ab \
&& git clean -df \
&& make \
&& make install
FROM postgres:14.5-alpine
COPY --from=builder /aiven-extras/aiven_extras.control /usr/local/share/postgresql/extension/aiven_extras.control
COPY --from=builder /aiven-extras/sql/aiven_extras.sql /usr/local/share/postgresql/extension/aiven_extras--1.1.9.sql
COPY --from=builder /aiven-extras/aiven_extras.so /usr/local/lib/postgresql/aiven_extras.so
EXPOSE 5432
CMD ["postgres"]
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = '22'
+2
View File
@@ -21,6 +21,7 @@
"dev:docker": "docker compose -f ./docker-compose-deps.yml",
"dev:docker:up": "docker compose -f ./docker-compose-deps.yml up -d",
"dev:docker:down": "docker compose -f ./docker-compose-deps.yml down",
"dev:docker:restart": "yarn dev:docker:down && yarn dev:docker:up",
"dev:kind:up": "ctlptl apply --filename ./.circleci/deployment/cluster-config.yaml",
"dev:kind:down": "ctlptl delete -f ./.circleci/deployment/cluster-config.yaml",
"dev:kind:helm:up": "yarn dev:kind:up && tilt up --file ./.circleci/deployment/Tiltfile.helm --context kind-speckle-server",
@@ -58,6 +59,7 @@
"husky": "^7.0.4",
"lint-staged": "^12.3.7",
"lockfile": "^1.0.4",
"npkill": "^0.12.2",
"pino-pretty": "^9.1.1",
"prettier": "^2.5.1",
"ts-node": "^10.9.1",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -80,7 +80,7 @@ function createCache(): InMemoryCache {
},
streams: {
keyArgs: ['query'],
merge: buildAbstractCollectionMergeFunction('StreamCollection', {
merge: buildAbstractCollectionMergeFunction('UserStreamCollection', {
checkIdentity: true
})
},
+4 -7
View File
@@ -1,11 +1,8 @@
import speckleTheme from '@speckle/tailwind-theme'
import { tailwindContentEntry as themeEntry } from '@speckle/tailwind-theme/tailwind-configure'
import { tailwindContentEntry as uiLibEntry } from '@speckle/ui-components/tailwind-configure'
import { tailwindContentEntries as themeEntries } from '@speckle/tailwind-theme/tailwind-configure'
import { tailwindContentEntries as uiLibEntries } from '@speckle/ui-components/tailwind-configure'
import formsPlugin from '@tailwindcss/forms'
import { createRequire } from 'module'
const req = createRequire(import.meta.url)
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: 'class',
@@ -19,8 +16,8 @@ const config = {
'./app.vue',
'./.storybook/**/*.{js,ts,vue}',
'./lib/**/composables/*.{js,ts}',
themeEntry(req),
uiLibEntry(req)
...themeEntries(),
...uiLibEntries()
// `./lib/**/*.{js,ts,vue}`, // TODO: Wait for fix https://github.com/nuxt/framework/issues/2886#issuecomment-1108312903
],
plugins: [speckleTheme, formsPlugin]
+5 -3
View File
@@ -5,11 +5,13 @@
"version": "0.2.0",
"configurations": [
{
"name": "Launch via NPM",
"name": "Launch via Yarn",
"request": "launch",
"runtimeArgs": ["run-script", "dev"],
"runtimeExecutable": "npm",
"console": "integratedTerminal",
"runtimeArgs": ["dev"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**"],
"envFile": "${workspaceFolder}/.env",
"type": "node"
}
]
+48 -64
View File
@@ -1,97 +1,81 @@
ARG NODE_ENV=production
FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS build-stage
FROM mcr.microsoft.com/dotnet/sdk:8.0-noble AS dotnet-build-stage
WORKDIR /app
COPY packages/fileimport-service/ifc-dotnet .
RUN dotnet publish ifc-converter.csproj -c Release -o output/
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
FROM mcr.microsoft.com/dotnet/runtime:8.0-noble AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /speckle-server
# install tini
# configure tini
ARG TINI_VERSION=v0.19.0
ENV TINI_VERSION=${TINI_VERSION}
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini ./tini
RUN chmod +x ./tini
# install wait
ARG WAIT_VERSION=2.8.0
ENV WAIT_VERSION=${WAIT_VERSION}
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${WAIT_VERSION}/wait ./wait
RUN chmod +x ./wait
# hadolint ignore=DL3008
RUN apt-get update -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends \
ca-certificates=20240203 \
curl=8.5.0-2ubuntu10.6 \
gosu=1.17-1ubuntu0.24.04.2 \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& curl -fsSL https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini -o /usr/bin/tini \
&& chmod +x /usr/bin/tini \
&& curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \
&& gosu root:root bash nodesource_setup.sh \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends \
nodejs \
&& echo ">>>>>> NODE Version: $(node --version)" \
&& npm install -g corepack@0.30.0 \
&& corepack enable \
&& DEBIAN_FRONTEND=noninteractive apt-get remove curl -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# download yarn dependencies for building shared libraries
COPY .yarnrc.yml .
COPY .yarn ./.yarn
COPY package.json yarn.lock ./
COPY packages/frontend-2/type-augmentations/stubs ./packages/frontend-2/type-augmentations/stubs/
COPY packages/shared/package.json ./packages/shared/
COPY packages/fileimport-service/package.json ./packages/fileimport-service/
COPY packages/frontend-2/type-augmentations/stubs packages/frontend-2/type-augmentations/stubs/
COPY packages/shared/package.json packages/shared/
COPY packages/fileimport-service/package.json packages/fileimport-service/
RUN yarn workspaces focus --all
# build shared libraries
COPY packages/shared ./packages/shared/
COPY packages/fileimport-service ./packages/fileimport-service/
COPY packages/shared packages/shared/
COPY packages/fileimport-service packages/fileimport-service/
RUN yarn workspaces foreach -W run build
# Install python virtual env and python dependencies
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends \
python3-venv=3.11.2-1+b1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m venv /venv
RUN apt-get update -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends \
python3.12=3.12.3-1ubuntu0.4 \
python3-pip=24.0+dfsg-1ubuntu1.1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY packages/fileimport-service/requirements.txt /speckle-server/
RUN /venv/bin/pip install --disable-pip-version-check --no-cache-dir --requirement /speckle-server/requirements.txt
FROM node:22-bookworm-slim@sha256:221ee67425de7a3c11ce4e81e63e50caaec82ede3a7d34599ab20e59d29a0cb5 AS dependency-stage
# installing just the production dependencies
# separate stage to avoid including development dependencies
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
WORKDIR /speckle-server
COPY .yarnrc.yml .
COPY .yarn ./.yarn
COPY package.json yarn.lock ./
COPY packages/frontend-2/type-augmentations/stubs ./packages/frontend-2/type-augmentations/stubs/
COPY packages/shared/package.json ./packages/shared/
COPY packages/fileimport-service/package.json ./packages/fileimport-service/
WORKDIR /speckle-server/packages/fileimport-service
RUN yarn workspaces focus --production
FROM gcr.io/distroless/python3-debian12:nonroot@sha256:14c62b8925d3bb30319de2f346bde203fe18103a68898284a62db9d4aa54c794 AS python-image
FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 AS distributable-stage
RUN pip install --break-system-packages --disable-pip-version-check --no-cache-dir --requirement /speckle-server/requirements.txt
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV}
ARG NODE_BINARY_PATH=/nodejs/bin/node
ARG NODE_BINARY_PATH=/usr/bin/node
ENV NODE_BINARY_PATH=${NODE_BINARY_PATH}
ARG PYTHON_BINARY_PATH=/venv/bin/python3
ARG PYTHON_BINARY_PATH=/usr/bin/python3
ENV PYTHON_BINARY_PATH=${PYTHON_BINARY_PATH}
ARG DOTNET_BINARY_PATH=/usr/bin/dotnet
ENV DOTNET_BINARY_PATH=${DOTNET_BINARY_PATH}
WORKDIR /speckle-server
COPY --from=python-image / /
COPY --from=build-stage /speckle-server/tini /usr/bin/tini
COPY --from=build-stage /speckle-server/wait /usr/bin/wait
COPY --from=build-stage /speckle-server/packages/shared ./packages/shared
COPY --from=build-stage /speckle-server/packages/fileimport-service ./packages/fileimport-service
COPY --from=build-stage /venv /venv
COPY --from=dependency-stage /speckle-server/node_modules ./node_modules
COPY --link --from=dotnet-build-stage /app/output packages/fileimport-service/ifc-dotnet
WORKDIR /speckle-server/packages/fileimport-service
# Prefixing PATH with our virtual environment should seek required binaries
# from virtual environment first.
# Unsetting python home
ENV PATH=/venv/bin:${PATH} \
PYTHONHOME=
ENTRYPOINT [ "tini", "--", "/nodejs/bin/node", "--no-experimental-fetch", "src/daemon.js"]
ENTRYPOINT [ "tini", "--", "node", "--no-experimental-fetch", "src/daemon.js"]
@@ -5,6 +5,9 @@ import { baseConfigs, globals } from '../../eslint.config.mjs'
*/
const configs = [
...baseConfigs,
{
ignores: ['**/ifc/**', '**/obj/**', '**/stl/**']
},
{
languageOptions: {
globals: {
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "0.30.1",
"commands": [
"dotnet-csharpier"
]
}
}
}
@@ -0,0 +1,33 @@
using Speckle.Sdk.Transports;
namespace Speckle.Converter;
public class ConsoleProgress : IProgress<ProgressArgs>
{
private readonly TimeSpan DEBOUNCE = TimeSpan.FromSeconds(1);
private DateTime _lastTime = DateTime.UtcNow;
private long _totalBytes;
public void Report(ProgressArgs value)
{
if (value.ProgressEvent == ProgressEvent.DownloadBytes)
{
Interlocked.Add(ref _totalBytes, value.Count);
}
var now = DateTime.UtcNow;
if (now - _lastTime >= DEBOUNCE)
{
if (value.ProgressEvent == ProgressEvent.DownloadBytes)
{
Console.WriteLine(value.ProgressEvent + " t " + _totalBytes);
}
else
{
Console.WriteLine(value.ProgressEvent + " c " + value.Count + " t " + value.Total);
}
_lastTime = now;
}
}
}
@@ -0,0 +1,47 @@
using System.CommandLine;
using System.Text.Json;
using Speckle.Sdk.Common;
using Speckle.WebIfc.Importer;
var filePathArgument = new Argument<string>(name: "filePath");
var outputPathArgument = new Argument<string>("outputPath");
var streamIdArgument = new Argument<string>("streamId");
var commitMessageArgument = new Argument<string>("commitMessage");
var modelIdArgument = new Argument<string>("modelId");
var regionNameArgument = new Argument<string>("regionName");
var rootCommand = new RootCommand
{
filePathArgument,
outputPathArgument,
streamIdArgument,
commitMessageArgument,
modelIdArgument,
regionNameArgument,
};
rootCommand.SetHandler(
async (filePath, outputPath, streamId, commitMessage, modelId, _) =>
{
try
{
var token = Environment.GetEnvironmentVariable("USER_TOKEN").NotNull("USER_TOKEN is missing");
var url = Environment.GetEnvironmentVariable("SPECKLE_SERVER_URL") ?? "http://127.0.0.1:3000";
var commitId = await Import.Ifc(url, filePath, streamId, modelId, commitMessage, token);
File.WriteAllText(outputPath, JsonSerializer.Serialize(new { success = true, commitId }));
}
catch (Exception e)
{
File.WriteAllText(
outputPath,
JsonSerializer.Serialize(new { success = false, error = e.ToString() })
);
}
},
filePathArgument,
outputPathArgument,
streamIdArgument,
commitMessageArgument,
modelIdArgument,
regionNameArgument
);
await rootCommand.InvokeAsync(args);
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Speckle.Converter</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.7" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ifc-converter", "ifc-converter.csproj", "{4D63FBD3-8ABF-4F51-A08F-740B17BDCA28}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4D63FBD3-8ABF-4F51-A08F-740B17BDCA28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D63FBD3-8ABF-4F51-A08F-740B17BDCA28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D63FBD3-8ABF-4F51-A08F-740B17BDCA28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D63FBD3-8ABF-4F51-A08F-740B17BDCA28}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
@@ -1,421 +1,421 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('IFC2X3.exp'),'2;1');
FILE_NAME('C:\\TeklaStructuresModels\\Acis_Sat\\plate_steel_example-tek_1fix.ifc','2006-05-12T10:07:38',('Steel2 macro version:12.0 Build:179423,2.5.2006'),('Structural Designer'),'EXPRESS Data Manager version:20040806','Tekla Structures 12.0','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1= IFCPERSON('TEKLAAD/lli','Undefined',$,$,$,$,$,$);
#3= IFCORGANIZATION($,'Tekla Corporation',$,$,$);
#7= IFCPERSONANDORGANIZATION(#1,#3,$);
#8= IFCAPPLICATION(#3,'12.0','Tekla Structures','Multi material modeling');
#9= IFCOWNERHISTORY(#7,#8,$,.ADDED.,$,$,$,1147417657);
#10= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#11= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#12= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#13= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
#14= IFCSIUNIT(*,.SOLIDANGLEUNIT.,$,.STERADIAN.);
#15= IFCSIUNIT(*,.MASSUNIT.,.KILO.,.GRAM.);
#16= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);
#17= IFCSIUNIT(*,.THERMODYNAMICTEMPERATUREUNIT.,$,.DEGREE_CELSIUS.);
#18= IFCSIUNIT(*,.LUMINOUSINTENSITYUNIT.,$,.LUMEN.);
#19= IFCUNITASSIGNMENT((#10,#11,#12,#13,#14,#15,#16,#17,#18));
#21= IFCCARTESIANPOINT((0.,0.,0.));
#25= IFCDIRECTION((1.,0.,0.));
#29= IFCDIRECTION((0.,1.,0.));
#33= IFCDIRECTION((0.,0.,1.));
#37= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#40= IFCGEOMETRICREPRESENTATIONCONTEXT('Plan','Design',3,1.0000000E-5,#37,$);
#43= IFCGEOMETRICREPRESENTATIONCONTEXT('Plan','Sketch',3,1.0000000E-5,#37,$);
#46= IFCPROJECT('2gPUQOiNz2FR1H6lWQ8j0k',#9,'PROJ: NAME','Description','Object type','LongName','Phase',(#40,#43),#19);
#53= IFCMATERIAL('A36');
#56= IFCMATERIAL('A992');
#59= IFCMATERIAL('A500-GR.B');
#62= IFCPERSON('TEKLAAD/chke','Undefined',$,$,$,$,$,$);
#64= IFCPERSONANDORGANIZATION(#62,#3,$);
#65= IFCOWNERHISTORY(#64,#8,$,.ADDED.,$,$,$,1147417657);
#66= IFCSITE('1_WapmNXfFdOqQ3garaDJn',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$,$,$,$,$);
#76= IFCRELAGGREGATES('36yaCMhuT2DxfpF3ieGira',#65,$,$,#46,(#66));
#78= IFCBUILDING('2iHnVT4$n9JQE18R_2cJEI',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$,$,$);
#88= IFCRELAGGREGATES('0A543zcq1Fdv_lsrHHX8Kv',#65,$,$,#66,(#78));
#90= IFCBUILDINGSTOREY('0UguZM0$L8L9OUA114_ZEd',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$);
#100= IFCRELAGGREGATES('09$Ux3Y999qBeEKV8wCsfA',#65,$,$,#78,(#90));
#102= IFCPOLYLINE((#106,#111,#115,#119,#123,#127,#131));
#106= IFCCARTESIANPOINT((0.,318.8494));
#111= IFCCARTESIANPOINT((95.250002,318.8494));
#115= IFCCARTESIANPOINT((120.65,293.4494));
#119= IFCCARTESIANPOINT((120.65,25.4));
#123= IFCCARTESIANPOINT((95.250002,0.));
#127= IFCCARTESIANPOINT((0.,0.));
#131= IFCCARTESIANPOINT((0.,318.8494));
#135= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL19.1',#102);
#136= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#139= IFCEXTRUDEDAREASOLID(#135,#136,#33,19.1);
#142= IFCCARTESIANPOINT((0.,0.,0.));
#146= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#139));
#152= IFCPRODUCTDEFINITIONSHAPE($,$,(#146));
#156= IFCCARTESIANPOINT((5240.3064,7493.,-337.7327));
#160= IFCDIRECTION((1.,0.,0.));
#164= IFCDIRECTION((0.,1.,0.));
#168= IFCAXIS2PLACEMENT3D(#156,#160,#164);
#171= IFCLOCALPLACEMENT($,#168);
#174= IFCPLATE('13c6Dt0003iJ4nCpGmDZ4q',#65,'PLATE','PL19.1',$,#171,#152,$);
#193= IFCRELCONTAINEDINSPATIALSTRUCTURE('0119IaMyb8cxCzo7l7Qnbw',#65,$,$,(#174,#268,#350,#434,#521,#633,#720,#808,#895,#982,#1078,#1193,#1385,#1473),#90);
#195= IFCSTRUCTURALPROFILEPROPERTIES('PL19.1',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#196= IFCPOLYLINE((#200,#204,#208,#212,#216,#220,#224));
#200= IFCCARTESIANPOINT((0.,293.4494));
#204= IFCCARTESIANPOINT((25.4,318.8494));
#208= IFCCARTESIANPOINT((120.65,318.8494));
#212= IFCCARTESIANPOINT((120.65,0.));
#216= IFCCARTESIANPOINT((25.4,0.));
#220= IFCCARTESIANPOINT((0.,25.4));
#224= IFCCARTESIANPOINT((0.,293.4494));
#228= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL19.1',#196);
#229= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#232= IFCEXTRUDEDAREASOLID(#228,#229,#33,19.1);
#235= IFCCARTESIANPOINT((0.,0.,0.));
#240= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#232));
#246= IFCPRODUCTDEFINITIONSHAPE($,$,(#240));
#250= IFCCARTESIANPOINT((5240.3064,7626.35,-337.7327));
#254= IFCDIRECTION((1.,0.,0.));
#258= IFCDIRECTION((0.,1.,0.));
#262= IFCAXIS2PLACEMENT3D(#250,#254,#258);
#265= IFCLOCALPLACEMENT($,#262);
#268= IFCPLATE('13c6Dt0003gZ4nCpGmDZ4q',#65,'PLATE','PL19.1',$,#265,#246,$);
#287= IFCPOLYLINE((#291,#295,#299,#303,#307));
#291= IFCCARTESIANPOINT((0.,0.));
#295= IFCCARTESIANPOINT((0.,318.45248));
#299= IFCCARTESIANPOINT((99.695007,318.45248));
#303= IFCCARTESIANPOINT((99.695007,0.));
#307= IFCCARTESIANPOINT((0.,0.));
#311= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#287);
#312= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#315= IFCEXTRUDEDAREASOLID(#311,#312,#33,9.5);
#318= IFCCARTESIANPOINT((0.,0.,0.));
#322= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#315));
#328= IFCPRODUCTDEFINITIONSHAPE($,$,(#322));
#332= IFCCARTESIANPOINT((4411.98,7492.5555,-342.9125));
#336= IFCDIRECTION((0.,0.,-1.));
#340= IFCDIRECTION((0.,1.,0.));
#344= IFCAXIS2PLACEMENT3D(#332,#336,#340);
#347= IFCLOCALPLACEMENT($,#344);
#350= IFCPLATE('13c6Dt0002ip4nCpGmDZ4o',#65,'PLATE','PL9.5',$,#347,#328,$);
#370= IFCSTRUCTURALPROFILEPROPERTIES('PL9.5',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#371= IFCPOLYLINE((#375,#379,#383,#387,#391));
#375= IFCCARTESIANPOINT((0.,0.));
#379= IFCCARTESIANPOINT((0.,318.45248));
#383= IFCCARTESIANPOINT((99.695007,318.45248));
#387= IFCCARTESIANPOINT((99.695007,0.));
#391= IFCCARTESIANPOINT((0.,0.));
#395= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#371);
#396= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#399= IFCEXTRUDEDAREASOLID(#395,#396,#33,9.5);
#402= IFCCARTESIANPOINT((0.,0.,0.));
#406= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#399));
#412= IFCPRODUCTDEFINITIONSHAPE($,$,(#406));
#416= IFCCARTESIANPOINT((4411.98,7492.5555,-28.5875));
#420= IFCDIRECTION((0.,0.,-1.));
#424= IFCDIRECTION((0.,1.,0.));
#428= IFCAXIS2PLACEMENT3D(#416,#420,#424);
#431= IFCLOCALPLACEMENT($,#428);
#434= IFCPLATE('13c6Dt0002hp4nCpGmDZ4o',#65,'PLATE','PL9.5',$,#431,#412,$);
#453= IFCCARTESIANPOINT((0.,0.));
#457= IFCDIRECTION((1.,0.));
#461= IFCAXIS2PLACEMENT2D(#453,#457);
#464= IFCRECTANGLEPROFILEDEF(.AREA.,$,#461,9.5,234.9);
#465= IFCDIRECTION((0.,0.,-1.));
#469= IFCDIRECTION((-1.,0.,0.));
#473= IFCAXIS2PLACEMENT3D(#21,#469,#33);
#476= IFCEXTRUDEDAREASOLID(#464,#473,#465,304.8);
#479= IFCCARTESIANPOINT((0.,-117.45,-4.75));
#483= IFCBOUNDINGBOX(#479,304.8,234.9,9.5);
#486= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#476));
#492= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#483));
#499= IFCPRODUCTDEFINITIONSHAPE($,$,(#486,#492));
#503= IFCCARTESIANPOINT((4581.8225,7496.2,-342.9));
#507= IFCDIRECTION((-1.,0.,0.));
#511= IFCDIRECTION((0.,0.,1.));
#515= IFCAXIS2PLACEMENT3D(#503,#507,#511);
#518= IFCLOCALPLACEMENT($,#515);
#521= IFCCOLUMN('13c6Dt0002c34nCpGmDZ4o',#65,'PLATE','PL9.5X234.9',$,#518,#499,$);
#540= IFCSTRUCTURALPROFILEPROPERTIES('PL9.5X234.9',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#541= IFCCARTESIANPOINT((0.,0.));
#545= IFCDIRECTION((1.,0.));
#549= IFCAXIS2PLACEMENT2D(#541,#545);
#552= IFCISHAPEPROFILEDEF(.AREA.,$,#549,177.41901,402.84399,9.5249996,10.922,17.653);
#553= IFCDIRECTION((0.,0.,-1.));
#557= IFCDIRECTION((-1.,0.,0.));
#561= IFCAXIS2PLACEMENT3D(#21,#557,#33);
#564= IFCEXTRUDEDAREASOLID(#552,#561,#553,2278.7992);
#567= IFCCARTESIANPOINT((0.,-201.422,-88.709503));
#571= IFCBOUNDINGBOX(#567,778.79918,402.84399,177.41901);
#574= IFCCARTESIANPOINT((778.79918,301.422,0.));
#578= IFCDIRECTION((1.,0.,0.));
#582= IFCDIRECTION((0.,0.,-1.));
#586= IFCAXIS2PLACEMENT3D(#574,#578,#582);
#589= IFCPLANE(#586);
#592= IFCHALFSPACESOLID(#589,.F.);
#595= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#564,#592);
#598= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#595));
#604= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#571));
#610= IFCPRODUCTDEFINITIONSHAPE($,$,(#598,#604));
#614= IFCCARTESIANPOINT((4572.,6688.3563,-201.422));
#618= IFCDIRECTION((1.,0.,0.));
#623= IFCDIRECTION((0.,1.,0.));
#627= IFCAXIS2PLACEMENT3D(#614,#618,#623);
#630= IFCLOCALPLACEMENT($,#627);
#633= IFCBEAM('13c6Dt0001iZ4nCpGmDZ0v',#65,'BEAM','W16X36',$,#630,#610,$);
#652= IFCSTRUCTURALPROFILEPROPERTIES('W16X36',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#653= IFCCARTESIANPOINT((0.,0.));
#657= IFCDIRECTION((1.,0.));
#661= IFCAXIS2PLACEMENT2D(#653,#657);
#664= IFCLSHAPEPROFILEDEF(.AREA.,$,#661,101.6,88.900002,9.5249996,9.525,$,$,$,$);
#665= IFCDIRECTION((0.,0.,-1.));
#669= IFCDIRECTION((-1.,0.,0.));
#673= IFCAXIS2PLACEMENT3D(#21,#669,#33);
#676= IFCEXTRUDEDAREASOLID(#664,#673,#665,254.);
#679= IFCCARTESIANPOINT((0.,-50.799999,-44.450001));
#683= IFCBOUNDINGBOX(#679,254.,101.6,88.900002);
#686= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#676));
#692= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#683));
#698= IFCPRODUCTDEFINITIONSHAPE($,$,(#686,#692));
#702= IFCCARTESIANPOINT((4794.758,7562.85,-298.45));
#706= IFCDIRECTION((1.,0.,0.));
#710= IFCDIRECTION((0.,0.,1.));
#714= IFCAXIS2PLACEMENT3D(#702,#706,#710);
#717= IFCLOCALPLACEMENT($,#714);
#720= IFCCOLUMN('13c6Dt0001GZ4nCpGmDZ0u',#65,'ANGLE','L4X3-1/2X3/8',$,#717,#698,$);
#739= IFCSTRUCTURALPROFILEPROPERTIES('L4X3-1/2X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#740= IFCCARTESIANPOINT((0.,0.));
#744= IFCDIRECTION((1.,0.));
#748= IFCAXIS2PLACEMENT2D(#740,#744);
#752= IFCLSHAPEPROFILEDEF(.AREA.,$,#748,101.6,88.900002,9.5249996,9.525,$,$,$,$);
#753= IFCDIRECTION((0.,0.,-1.));
#757= IFCDIRECTION((-1.,0.,0.));
#761= IFCAXIS2PLACEMENT3D(#21,#757,#33);
#764= IFCEXTRUDEDAREASOLID(#752,#761,#753,254.);
#767= IFCCARTESIANPOINT((0.,-50.799999,-44.450001));
#771= IFCBOUNDINGBOX(#767,254.,101.6,88.900002);
#774= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#764));
#780= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#771));
#786= IFCPRODUCTDEFINITIONSHAPE($,$,(#774,#780));
#790= IFCCARTESIANPOINT((4794.758,7677.15,-44.45));
#794= IFCDIRECTION((1.,0.,0.));
#798= IFCDIRECTION((0.,0.,-1.));
#802= IFCAXIS2PLACEMENT3D(#790,#794,#798);
#805= IFCLOCALPLACEMENT($,#802);
#808= IFCBEAM('13c6Dt0001FZ4nCpGmDZ0u',#65,'ANGLE','L4X3-1/2X3/8',$,#805,#786,$);
#827= IFCCARTESIANPOINT((0.,0.));
#831= IFCDIRECTION((1.,0.));
#835= IFCAXIS2PLACEMENT2D(#827,#831);
#838= IFCLSHAPEPROFILEDEF(.AREA.,$,#835,101.6,101.60025,9.5249996,9.525,$,$,$,$);
#839= IFCDIRECTION((0.,0.,-1.));
#843= IFCDIRECTION((-1.,0.,0.));
#847= IFCAXIS2PLACEMENT3D(#21,#843,#33);
#850= IFCEXTRUDEDAREASOLID(#838,#847,#839,292.1);
#853= IFCCARTESIANPOINT((0.,-50.799999,-50.800125));
#857= IFCBOUNDINGBOX(#853,292.1,101.6,101.60025);
#860= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#850));
#866= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#857));
#872= IFCPRODUCTDEFINITIONSHAPE($,$,(#860,#866));
#876= IFCCARTESIANPOINT((4915.409,7564.4494,50.799999));
#881= IFCDIRECTION((0.,-1.,0.));
#885= IFCDIRECTION((1.,0.,0.));
#889= IFCAXIS2PLACEMENT3D(#876,#881,#885);
#892= IFCLOCALPLACEMENT($,#889);
#895= IFCBEAM('13c6Dt0000F34nCpGmDZ0s',#65,'BEAM','L4X4X3/8',$,#892,#872,$);
#914= IFCSTRUCTURALPROFILEPROPERTIES('L4X4X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#915= IFCCARTESIANPOINT((0.,0.));
#919= IFCDIRECTION((1.,0.));
#923= IFCAXIS2PLACEMENT2D(#915,#919);
#926= IFCLSHAPEPROFILEDEF(.AREA.,$,#923,101.6,101.60025,9.5249996,9.525,$,$,$,$);
#927= IFCDIRECTION((0.,0.,-1.));
#931= IFCDIRECTION((-1.,0.,0.));
#935= IFCAXIS2PLACEMENT3D(#21,#931,#33);
#938= IFCEXTRUDEDAREASOLID(#926,#935,#927,330.2);
#941= IFCCARTESIANPOINT((0.,-50.799999,-50.800125));
#945= IFCBOUNDINGBOX(#941,330.2,101.6,101.60025);
#948= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#938));
#954= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#945));
#960= IFCPRODUCTDEFINITIONSHAPE($,$,(#948,#954));
#964= IFCCARTESIANPOINT((4801.108,7564.4494,495.301));
#968= IFCDIRECTION((0.,-1.,0.));
#972= IFCDIRECTION((0.,0.,-1.));
#976= IFCAXIS2PLACEMENT3D(#964,#968,#972);
#979= IFCLOCALPLACEMENT($,#976);
#982= IFCBEAM('13c6Dt00006J4nCpGmDZ0s',#65,'BEAM','L4X4X3/8',$,#979,#960,$);
#1001= IFCPOLYLINE((#1006,#1010,#1014,#1018,#1022,#1026));
#1006= IFCCARTESIANPOINT((0.,0.));
#1010= IFCCARTESIANPOINT((-0.001,486.84845));
#1014= IFCCARTESIANPOINT((369.07135,486.84845));
#1018= IFCCARTESIANPOINT((512.75543,343.16437));
#1022= IFCCARTESIANPOINT((512.75543,0.));
#1026= IFCCARTESIANPOINT((0.,0.));
#1030= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#1001);
#1031= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#1034= IFCEXTRUDEDAREASOLID(#1030,#1031,#33,9.5);
#1037= IFCCARTESIANPOINT((-0.0010532137,0.,-4.75));
#1041= IFCBOUNDINGBOX(#1037,486.8495,512.75614,9.5);
#1044= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1034));
#1050= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1041));
#1056= IFCPRODUCTDEFINITIONSHAPE($,$,(#1044,#1050));
#1060= IFCCARTESIANPOINT((4763.008,7615.262,12.701));
#1064= IFCDIRECTION((0.,1.,0.));
#1068= IFCDIRECTION((0.,0.,1.));
#1072= IFCAXIS2PLACEMENT3D(#1060,#1064,#1068);
#1075= IFCLOCALPLACEMENT($,#1072);
#1078= IFCPLATE('13c6Dt00005J4nCpGmDZ0s',#65,'PLATE','PL9.5',$,#1075,#1056,$);
#1097= IFCCARTESIANPOINT((0.,0.));
#1101= IFCDIRECTION((1.,0.));
#1105= IFCAXIS2PLACEMENT2D(#1097,#1101);
#1108= IFCRECTANGLEHOLLOWPROFILEDEF(.AREA.,$,#1105,152.39999,152.39999,9.5249996,9.5250004,19.05);
#1109= IFCDIRECTION((0.,0.,-1.));
#1113= IFCDIRECTION((-1.,0.,0.));
#1117= IFCAXIS2PLACEMENT3D(#1130,#1113,#33);
#1120= IFCEXTRUDEDAREASOLID(#1108,#1117,#1109,2177.4228);
#1123= IFCCARTESIANPOINT((-1.2141754E-10,-76.199997,-76.199997));
#1127= IFCBOUNDINGBOX(#1123,677.4228,152.39999,152.39999);
#1130= IFCCARTESIANPOINT((-1500.,0.,0.));
#1135= IFCCARTESIANPOINT((-1.0913936E-10,-9.0949470E-12,4.7379999));
#1139= IFCDIRECTION((-1.,-1.6052583E-13,0.));
#1143= IFCDIRECTION((-1.6052583E-13,1.,0.));
#1147= IFCAXIS2PLACEMENT3D(#1135,#1139,#1143);
#1150= IFCPLANE(#1147);
#1153= IFCHALFSPACESOLID(#1150,.F.);
#1156= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#1120,#1153);
#1159= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#1156));
#1165= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1127));
#1171= IFCPRODUCTDEFINITIONSHAPE($,$,(#1159,#1165));
#1175= IFCCARTESIANPOINT((5007.3895,7620.,282.98953));
#1179= IFCDIRECTION((0.,-1.,0.));
#1183= IFCDIRECTION((0.70710678,0.,0.70710678));
#1187= IFCAXIS2PLACEMENT3D(#1175,#1179,#1183);
#1190= IFCLOCALPLACEMENT($,#1187);
#1193= IFCBEAM('13c6Dt00003p4nCpGmDZ0r',#65,'BRACE','TS6X6X3/8',$,#1190,#1171,$);
#1212= IFCCARTESIANPOINT((5153.6546,7620.012,307.34937));
#1216= IFCDIRECTION((0.,-1.,0.));
#1220= IFCDIRECTION((-0.70710678,0.,0.70710678));
#1224= IFCAXIS2PLACEMENT3D(#1212,#1216,#1220);
#1227= IFCLOCALPLACEMENT($,#1224);
#1230= IFCCARTESIANPOINT((0.,0.));
#1234= IFCDIRECTION((1.,0.));
#1238= IFCAXIS2PLACEMENT2D(#1230,#1234);
#1241= IFCRECTANGLEPROFILEDEF(.AREA.,$,#1238,9.5,266.7);
#1242= IFCDIRECTION((0.,0.,-1.));
#1246= IFCDIRECTION((-1.,0.,0.));
#1250= IFCAXIS2PLACEMENT3D(#21,#1246,#33);
#1253= IFCEXTRUDEDAREASOLID(#1241,#1250,#1242,172.39997);
#1256= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1253));
#1263= IFCPRODUCTDEFINITIONSHAPE($,$,(#1256));
#1267= IFCOPENINGELEMENT('13c6Dt0000Ip4nCpGmDZ0s',#65,$,$,$,#1227,#1263,$);
#1288= IFCRELVOIDSELEMENT('3gz_1FcMHExhEMJzH8zl$M',#65,$,$,#1193,#1267);
#1289= IFCSTRUCTURALPROFILEPROPERTIES('TS6X6X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#1290= IFCCARTESIANPOINT((0.,0.));
#1294= IFCDIRECTION((1.,0.));
#1298= IFCAXIS2PLACEMENT2D(#1290,#1294);
#1301= IFCISHAPEPROFILEDEF(.AREA.,$,#1298,254.88901,356.616,12.7,18.288,21.3995);
#1302= IFCDIRECTION((0.,0.,-1.));
#1306= IFCDIRECTION((-1.,0.,0.));
#1310= IFCAXIS2PLACEMENT3D(#1323,#1306,#33);
#1313= IFCEXTRUDEDAREASOLID(#1301,#1310,#1302,2768.2595);
#1316= IFCCARTESIANPOINT((0.,-178.308,-127.4445));
#1320= IFCBOUNDINGBOX(#1316,1268.2595,356.616,254.88901);
#1323= IFCCARTESIANPOINT((-1500.,0.,0.));
#1327= IFCCARTESIANPOINT((0.,278.308,0.));
#1331= IFCDIRECTION((-1.,0.,0.));
#1335= IFCDIRECTION((0.,0.,1.));
#1339= IFCAXIS2PLACEMENT3D(#1327,#1331,#1335);
#1342= IFCPLANE(#1339);
#1345= IFCHALFSPACESOLID(#1342,.F.);
#1348= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#1313,#1345);
#1351= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#1348));
#1357= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1320));
#1363= IFCPRODUCTDEFINITIONSHAPE($,$,(#1351,#1357));
#1367= IFCCARTESIANPOINT((4763.008,7620.,-178.308));
#1371= IFCDIRECTION((0.,-1.,0.));
#1375= IFCDIRECTION((1.,0.,-7.2832534E-15));
#1379= IFCAXIS2PLACEMENT3D(#1367,#1371,#1375);
#1382= IFCLOCALPLACEMENT($,#1379);
#1385= IFCBEAM('13c6Dt00002p4nCpGmDZ0r',#65,'BEAM','W14X68',$,#1382,#1363,$);
#1405= IFCSTRUCTURALPROFILEPROPERTIES('W14X68',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#1406= IFCCARTESIANPOINT((0.,0.));
#1410= IFCDIRECTION((1.,0.));
#1414= IFCAXIS2PLACEMENT2D(#1406,#1410);
#1417= IFCISHAPEPROFILEDEF(.AREA.,$,#1414,254.88901,356.616,12.7,18.288,21.3995);
#1418= IFCDIRECTION((0.,0.,-1.));
#1422= IFCDIRECTION((-1.,0.,0.));
#1426= IFCAXIS2PLACEMENT3D(#21,#1422,#33);
#1429= IFCEXTRUDEDAREASOLID(#1417,#1426,#1418,1524.);
#1432= IFCCARTESIANPOINT((0.,-178.308,-127.4445));
#1436= IFCBOUNDINGBOX(#1432,1524.,356.616,254.88901);
#1439= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1429));
#1445= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1436));
#1451= IFCPRODUCTDEFINITIONSHAPE($,$,(#1439,#1445));
#1455= IFCCARTESIANPOINT((4572.,7620.,-609.6));
#1459= IFCDIRECTION((0.,-1.,0.));
#1463= IFCDIRECTION((7.1613756E-15,0.,1.));
#1467= IFCAXIS2PLACEMENT3D(#1455,#1459,#1463);
#1470= IFCLOCALPLACEMENT($,#1467);
#1473= IFCCOLUMN('13c6Dt00001p4nCpGmDZ0q',#65,'COLUMN','W14X68',$,#1470,#1451,$);
#1492= IFCCARTESIANPOINT((4661.154,7490.05,308.83104));
#1496= IFCDIRECTION((0.,1.,0.));
#1500= IFCDIRECTION((-1.,0.,7.0134555E-15));
#1504= IFCAXIS2PLACEMENT3D(#1492,#1496,#1500);
#1507= IFCLOCALPLACEMENT($,#1504);
#1510= IFCPOLYLINE((#1514,#1519,#1523,#1527,#1531,#1535,#1539,#1543,#1547,#1551,#1555,#1559,#1563,#1567,#1571,#1575,#1579,#1583,#1587,#1591,#1595,#1599,#1603,#1607,#1611,#1615,#1619,#1623,#1627,#1631,#1635,#1639,#1643,#1648,#1652,#1656,#1660));
#1514= IFCCARTESIANPOINT((0.38429439,16.098194));
#1519= IFCCARTESIANPOINT((0.,20.));
#1523= IFCCARTESIANPOINT((0.,336.616));
#1527= IFCCARTESIANPOINT((0.38429439,340.5178));
#1531= IFCCARTESIANPOINT((1.5224093,344.26967));
#1535= IFCCARTESIANPOINT((3.3706078,347.7274));
#1539= IFCCARTESIANPOINT((5.8578644,350.75813));
#1543= IFCCARTESIANPOINT((8.8885953,353.24539));
#1547= IFCCARTESIANPOINT((12.346331,355.09359));
#1551= IFCCARTESIANPOINT((16.098194,356.2317));
#1555= IFCCARTESIANPOINT((20.,356.616));
#1559= IFCCARTESIANPOINT((158.308,356.616));
#1563= IFCCARTESIANPOINT((162.20981,356.2317));
#1567= IFCCARTESIANPOINT((165.96167,355.09359));
#1571= IFCCARTESIANPOINT((169.4194,353.24539));
#1575= IFCCARTESIANPOINT((172.45013,350.75813));
#1579= IFCCARTESIANPOINT((174.93739,347.7274));
#1583= IFCCARTESIANPOINT((176.78559,344.26967));
#1587= IFCCARTESIANPOINT((177.9237,340.5178));
#1591= IFCCARTESIANPOINT((178.308,336.616));
#1595= IFCCARTESIANPOINT((178.308,20.));
#1599= IFCCARTESIANPOINT((177.9237,16.098194));
#1603= IFCCARTESIANPOINT((176.78559,12.346331));
#1607= IFCCARTESIANPOINT((174.93739,8.8885953));
#1611= IFCCARTESIANPOINT((172.45013,5.8578644));
#1615= IFCCARTESIANPOINT((169.4194,3.3706078));
#1619= IFCCARTESIANPOINT((165.96167,1.5224093));
#1623= IFCCARTESIANPOINT((162.20981,0.38429439));
#1627= IFCCARTESIANPOINT((158.308,0.));
#1631= IFCCARTESIANPOINT((20.,0.));
#1635= IFCCARTESIANPOINT((16.098194,0.38429439));
#1639= IFCCARTESIANPOINT((12.346331,1.5224093));
#1643= IFCCARTESIANPOINT((8.8885953,3.3706078));
#1648= IFCCARTESIANPOINT((5.8578644,5.8578644));
#1652= IFCCARTESIANPOINT((3.3706078,8.8885953));
#1656= IFCCARTESIANPOINT((1.5224093,12.346331));
#1660= IFCCARTESIANPOINT((0.38429439,16.098194));
#1664= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL259.9',#1510);
#1665= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#1668= IFCEXTRUDEDAREASOLID(#1664,#1665,#33,259.9);
#1671= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1668));
#1677= IFCPRODUCTDEFINITIONSHAPE($,$,(#1671));
#1681= IFCOPENINGELEMENT('13c6Dt0002m34nCpGmDZ4o',#65,$,$,$,#1507,#1677,$);
#1702= IFCRELVOIDSELEMENT('2WI1gpRhj1dwB5Pft5vGvO',#65,$,$,#1473,#1681);
#1703= IFCRELASSOCIATESMATERIAL('1GOaWfy957kRCtp2R28$a9',#9,$,$,(#1193),#59);
#1705= IFCRELASSOCIATESMATERIAL('3Vqwp2DmT3TeKWHZyFzO7N',#9,$,$,(#1473,#1385,#633),#56);
#1707= IFCRELASSOCIATESMATERIAL('0SMrmHf_5BRAYDVih2VNTN',#9,$,$,(#1078,#982,#895,#808,#720,#521,#434,#350,#268,#174),#53);
#1709= IFCRELASSOCIATESPROFILEPROPERTIES('2mzP0x3pj2GwUm7Wy7Xfs0',#9,$,$,(#633),#652,$,$);
#1711= IFCRELASSOCIATESPROFILEPROPERTIES('1iUJLoCsfD6ug6keUDRGVW',#9,$,$,(#808),#739,$,$);
#1713= IFCRELASSOCIATESPROFILEPROPERTIES('1LidMEK_vEQuG8Js1MD1ND',#9,$,$,(#895,#982),#914,$,$);
#1715= IFCRELASSOCIATESPROFILEPROPERTIES('22dp9maVb0sAxZG8F2xTIR',#9,$,$,(#1193),#1289,$,$);
#1717= IFCRELASSOCIATESPROFILEPROPERTIES('36LPEeuXn6VgX3frK_urjy',#9,$,$,(#1385),#1405,$,$);
ENDSEC;
END-ISO-10303-21;
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('IFC2X3.exp'),'2;1');
FILE_NAME('C:\\TeklaStructuresModels\\Acis_Sat\\plate_steel_example-tek_1fix.ifc','2006-05-12T10:07:38',('Steel2 macro version:12.0 Build:179423,2.5.2006'),('Structural Designer'),'EXPRESS Data Manager version:20040806','Tekla Structures 12.0','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1= IFCPERSON('TEKLAAD/lli','Undefined',$,$,$,$,$,$);
#3= IFCORGANIZATION($,'Tekla Corporation',$,$,$);
#7= IFCPERSONANDORGANIZATION(#1,#3,$);
#8= IFCAPPLICATION(#3,'12.0','Tekla Structures','Multi material modeling');
#9= IFCOWNERHISTORY(#7,#8,$,.ADDED.,$,$,$,1147417657);
#10= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#11= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#12= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#13= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
#14= IFCSIUNIT(*,.SOLIDANGLEUNIT.,$,.STERADIAN.);
#15= IFCSIUNIT(*,.MASSUNIT.,.KILO.,.GRAM.);
#16= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);
#17= IFCSIUNIT(*,.THERMODYNAMICTEMPERATUREUNIT.,$,.DEGREE_CELSIUS.);
#18= IFCSIUNIT(*,.LUMINOUSINTENSITYUNIT.,$,.LUMEN.);
#19= IFCUNITASSIGNMENT((#10,#11,#12,#13,#14,#15,#16,#17,#18));
#21= IFCCARTESIANPOINT((0.,0.,0.));
#25= IFCDIRECTION((1.,0.,0.));
#29= IFCDIRECTION((0.,1.,0.));
#33= IFCDIRECTION((0.,0.,1.));
#37= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#40= IFCGEOMETRICREPRESENTATIONCONTEXT('Plan','Design',3,1.0000000E-5,#37,$);
#43= IFCGEOMETRICREPRESENTATIONCONTEXT('Plan','Sketch',3,1.0000000E-5,#37,$);
#46= IFCPROJECT('2gPUQOiNz2FR1H6lWQ8j0k',#9,'PROJ: NAME','Description','Object type','LongName','Phase',(#40,#43),#19);
#53= IFCMATERIAL('A36');
#56= IFCMATERIAL('A992');
#59= IFCMATERIAL('A500-GR.B');
#62= IFCPERSON('TEKLAAD/chke','Undefined',$,$,$,$,$,$);
#64= IFCPERSONANDORGANIZATION(#62,#3,$);
#65= IFCOWNERHISTORY(#64,#8,$,.ADDED.,$,$,$,1147417657);
#66= IFCSITE('1_WapmNXfFdOqQ3garaDJn',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$,$,$,$,$);
#76= IFCRELAGGREGATES('36yaCMhuT2DxfpF3ieGira',#65,$,$,#46,(#66));
#78= IFCBUILDING('2iHnVT4$n9JQE18R_2cJEI',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$,$,$);
#88= IFCRELAGGREGATES('0A543zcq1Fdv_lsrHHX8Kv',#65,$,$,#66,(#78));
#90= IFCBUILDINGSTOREY('0UguZM0$L8L9OUA114_ZEd',#65,'Undefined',$,$,$,$,$,.ELEMENT.,$);
#100= IFCRELAGGREGATES('09$Ux3Y999qBeEKV8wCsfA',#65,$,$,#78,(#90));
#102= IFCPOLYLINE((#106,#111,#115,#119,#123,#127,#131));
#106= IFCCARTESIANPOINT((0.,318.8494));
#111= IFCCARTESIANPOINT((95.250002,318.8494));
#115= IFCCARTESIANPOINT((120.65,293.4494));
#119= IFCCARTESIANPOINT((120.65,25.4));
#123= IFCCARTESIANPOINT((95.250002,0.));
#127= IFCCARTESIANPOINT((0.,0.));
#131= IFCCARTESIANPOINT((0.,318.8494));
#135= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL19.1',#102);
#136= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#139= IFCEXTRUDEDAREASOLID(#135,#136,#33,19.1);
#142= IFCCARTESIANPOINT((0.,0.,0.));
#146= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#139));
#152= IFCPRODUCTDEFINITIONSHAPE($,$,(#146));
#156= IFCCARTESIANPOINT((5240.3064,7493.,-337.7327));
#160= IFCDIRECTION((1.,0.,0.));
#164= IFCDIRECTION((0.,1.,0.));
#168= IFCAXIS2PLACEMENT3D(#156,#160,#164);
#171= IFCLOCALPLACEMENT($,#168);
#174= IFCPLATE('13c6Dt0003iJ4nCpGmDZ4q',#65,'PLATE','PL19.1',$,#171,#152,$);
#193= IFCRELCONTAINEDINSPATIALSTRUCTURE('0119IaMyb8cxCzo7l7Qnbw',#65,$,$,(#174,#268,#350,#434,#521,#633,#720,#808,#895,#982,#1078,#1193,#1385,#1473),#90);
#195= IFCSTRUCTURALPROFILEPROPERTIES('PL19.1',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#196= IFCPOLYLINE((#200,#204,#208,#212,#216,#220,#224));
#200= IFCCARTESIANPOINT((0.,293.4494));
#204= IFCCARTESIANPOINT((25.4,318.8494));
#208= IFCCARTESIANPOINT((120.65,318.8494));
#212= IFCCARTESIANPOINT((120.65,0.));
#216= IFCCARTESIANPOINT((25.4,0.));
#220= IFCCARTESIANPOINT((0.,25.4));
#224= IFCCARTESIANPOINT((0.,293.4494));
#228= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL19.1',#196);
#229= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#232= IFCEXTRUDEDAREASOLID(#228,#229,#33,19.1);
#235= IFCCARTESIANPOINT((0.,0.,0.));
#240= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#232));
#246= IFCPRODUCTDEFINITIONSHAPE($,$,(#240));
#250= IFCCARTESIANPOINT((5240.3064,7626.35,-337.7327));
#254= IFCDIRECTION((1.,0.,0.));
#258= IFCDIRECTION((0.,1.,0.));
#262= IFCAXIS2PLACEMENT3D(#250,#254,#258);
#265= IFCLOCALPLACEMENT($,#262);
#268= IFCPLATE('13c6Dt0003gZ4nCpGmDZ4q',#65,'PLATE','PL19.1',$,#265,#246,$);
#287= IFCPOLYLINE((#291,#295,#299,#303,#307));
#291= IFCCARTESIANPOINT((0.,0.));
#295= IFCCARTESIANPOINT((0.,318.45248));
#299= IFCCARTESIANPOINT((99.695007,318.45248));
#303= IFCCARTESIANPOINT((99.695007,0.));
#307= IFCCARTESIANPOINT((0.,0.));
#311= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#287);
#312= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#315= IFCEXTRUDEDAREASOLID(#311,#312,#33,9.5);
#318= IFCCARTESIANPOINT((0.,0.,0.));
#322= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#315));
#328= IFCPRODUCTDEFINITIONSHAPE($,$,(#322));
#332= IFCCARTESIANPOINT((4411.98,7492.5555,-342.9125));
#336= IFCDIRECTION((0.,0.,-1.));
#340= IFCDIRECTION((0.,1.,0.));
#344= IFCAXIS2PLACEMENT3D(#332,#336,#340);
#347= IFCLOCALPLACEMENT($,#344);
#350= IFCPLATE('13c6Dt0002ip4nCpGmDZ4o',#65,'PLATE','PL9.5',$,#347,#328,$);
#370= IFCSTRUCTURALPROFILEPROPERTIES('PL9.5',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#371= IFCPOLYLINE((#375,#379,#383,#387,#391));
#375= IFCCARTESIANPOINT((0.,0.));
#379= IFCCARTESIANPOINT((0.,318.45248));
#383= IFCCARTESIANPOINT((99.695007,318.45248));
#387= IFCCARTESIANPOINT((99.695007,0.));
#391= IFCCARTESIANPOINT((0.,0.));
#395= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#371);
#396= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#399= IFCEXTRUDEDAREASOLID(#395,#396,#33,9.5);
#402= IFCCARTESIANPOINT((0.,0.,0.));
#406= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#399));
#412= IFCPRODUCTDEFINITIONSHAPE($,$,(#406));
#416= IFCCARTESIANPOINT((4411.98,7492.5555,-28.5875));
#420= IFCDIRECTION((0.,0.,-1.));
#424= IFCDIRECTION((0.,1.,0.));
#428= IFCAXIS2PLACEMENT3D(#416,#420,#424);
#431= IFCLOCALPLACEMENT($,#428);
#434= IFCPLATE('13c6Dt0002hp4nCpGmDZ4o',#65,'PLATE','PL9.5',$,#431,#412,$);
#453= IFCCARTESIANPOINT((0.,0.));
#457= IFCDIRECTION((1.,0.));
#461= IFCAXIS2PLACEMENT2D(#453,#457);
#464= IFCRECTANGLEPROFILEDEF(.AREA.,$,#461,9.5,234.9);
#465= IFCDIRECTION((0.,0.,-1.));
#469= IFCDIRECTION((-1.,0.,0.));
#473= IFCAXIS2PLACEMENT3D(#21,#469,#33);
#476= IFCEXTRUDEDAREASOLID(#464,#473,#465,304.8);
#479= IFCCARTESIANPOINT((0.,-117.45,-4.75));
#483= IFCBOUNDINGBOX(#479,304.8,234.9,9.5);
#486= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#476));
#492= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#483));
#499= IFCPRODUCTDEFINITIONSHAPE($,$,(#486,#492));
#503= IFCCARTESIANPOINT((4581.8225,7496.2,-342.9));
#507= IFCDIRECTION((-1.,0.,0.));
#511= IFCDIRECTION((0.,0.,1.));
#515= IFCAXIS2PLACEMENT3D(#503,#507,#511);
#518= IFCLOCALPLACEMENT($,#515);
#521= IFCCOLUMN('13c6Dt0002c34nCpGmDZ4o',#65,'PLATE','PL9.5X234.9',$,#518,#499,$);
#540= IFCSTRUCTURALPROFILEPROPERTIES('PL9.5X234.9',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#541= IFCCARTESIANPOINT((0.,0.));
#545= IFCDIRECTION((1.,0.));
#549= IFCAXIS2PLACEMENT2D(#541,#545);
#552= IFCISHAPEPROFILEDEF(.AREA.,$,#549,177.41901,402.84399,9.5249996,10.922,17.653);
#553= IFCDIRECTION((0.,0.,-1.));
#557= IFCDIRECTION((-1.,0.,0.));
#561= IFCAXIS2PLACEMENT3D(#21,#557,#33);
#564= IFCEXTRUDEDAREASOLID(#552,#561,#553,2278.7992);
#567= IFCCARTESIANPOINT((0.,-201.422,-88.709503));
#571= IFCBOUNDINGBOX(#567,778.79918,402.84399,177.41901);
#574= IFCCARTESIANPOINT((778.79918,301.422,0.));
#578= IFCDIRECTION((1.,0.,0.));
#582= IFCDIRECTION((0.,0.,-1.));
#586= IFCAXIS2PLACEMENT3D(#574,#578,#582);
#589= IFCPLANE(#586);
#592= IFCHALFSPACESOLID(#589,.F.);
#595= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#564,#592);
#598= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#595));
#604= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#571));
#610= IFCPRODUCTDEFINITIONSHAPE($,$,(#598,#604));
#614= IFCCARTESIANPOINT((4572.,6688.3563,-201.422));
#618= IFCDIRECTION((1.,0.,0.));
#623= IFCDIRECTION((0.,1.,0.));
#627= IFCAXIS2PLACEMENT3D(#614,#618,#623);
#630= IFCLOCALPLACEMENT($,#627);
#633= IFCBEAM('13c6Dt0001iZ4nCpGmDZ0v',#65,'BEAM','W16X36',$,#630,#610,$);
#652= IFCSTRUCTURALPROFILEPROPERTIES('W16X36',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#653= IFCCARTESIANPOINT((0.,0.));
#657= IFCDIRECTION((1.,0.));
#661= IFCAXIS2PLACEMENT2D(#653,#657);
#664= IFCLSHAPEPROFILEDEF(.AREA.,$,#661,101.6,88.900002,9.5249996,9.525,$,$,$,$);
#665= IFCDIRECTION((0.,0.,-1.));
#669= IFCDIRECTION((-1.,0.,0.));
#673= IFCAXIS2PLACEMENT3D(#21,#669,#33);
#676= IFCEXTRUDEDAREASOLID(#664,#673,#665,254.);
#679= IFCCARTESIANPOINT((0.,-50.799999,-44.450001));
#683= IFCBOUNDINGBOX(#679,254.,101.6,88.900002);
#686= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#676));
#692= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#683));
#698= IFCPRODUCTDEFINITIONSHAPE($,$,(#686,#692));
#702= IFCCARTESIANPOINT((4794.758,7562.85,-298.45));
#706= IFCDIRECTION((1.,0.,0.));
#710= IFCDIRECTION((0.,0.,1.));
#714= IFCAXIS2PLACEMENT3D(#702,#706,#710);
#717= IFCLOCALPLACEMENT($,#714);
#720= IFCCOLUMN('13c6Dt0001GZ4nCpGmDZ0u',#65,'ANGLE','L4X3-1/2X3/8',$,#717,#698,$);
#739= IFCSTRUCTURALPROFILEPROPERTIES('L4X3-1/2X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#740= IFCCARTESIANPOINT((0.,0.));
#744= IFCDIRECTION((1.,0.));
#748= IFCAXIS2PLACEMENT2D(#740,#744);
#752= IFCLSHAPEPROFILEDEF(.AREA.,$,#748,101.6,88.900002,9.5249996,9.525,$,$,$,$);
#753= IFCDIRECTION((0.,0.,-1.));
#757= IFCDIRECTION((-1.,0.,0.));
#761= IFCAXIS2PLACEMENT3D(#21,#757,#33);
#764= IFCEXTRUDEDAREASOLID(#752,#761,#753,254.);
#767= IFCCARTESIANPOINT((0.,-50.799999,-44.450001));
#771= IFCBOUNDINGBOX(#767,254.,101.6,88.900002);
#774= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#764));
#780= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#771));
#786= IFCPRODUCTDEFINITIONSHAPE($,$,(#774,#780));
#790= IFCCARTESIANPOINT((4794.758,7677.15,-44.45));
#794= IFCDIRECTION((1.,0.,0.));
#798= IFCDIRECTION((0.,0.,-1.));
#802= IFCAXIS2PLACEMENT3D(#790,#794,#798);
#805= IFCLOCALPLACEMENT($,#802);
#808= IFCBEAM('13c6Dt0001FZ4nCpGmDZ0u',#65,'ANGLE','L4X3-1/2X3/8',$,#805,#786,$);
#827= IFCCARTESIANPOINT((0.,0.));
#831= IFCDIRECTION((1.,0.));
#835= IFCAXIS2PLACEMENT2D(#827,#831);
#838= IFCLSHAPEPROFILEDEF(.AREA.,$,#835,101.6,101.60025,9.5249996,9.525,$,$,$,$);
#839= IFCDIRECTION((0.,0.,-1.));
#843= IFCDIRECTION((-1.,0.,0.));
#847= IFCAXIS2PLACEMENT3D(#21,#843,#33);
#850= IFCEXTRUDEDAREASOLID(#838,#847,#839,292.1);
#853= IFCCARTESIANPOINT((0.,-50.799999,-50.800125));
#857= IFCBOUNDINGBOX(#853,292.1,101.6,101.60025);
#860= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#850));
#866= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#857));
#872= IFCPRODUCTDEFINITIONSHAPE($,$,(#860,#866));
#876= IFCCARTESIANPOINT((4915.409,7564.4494,50.799999));
#881= IFCDIRECTION((0.,-1.,0.));
#885= IFCDIRECTION((1.,0.,0.));
#889= IFCAXIS2PLACEMENT3D(#876,#881,#885);
#892= IFCLOCALPLACEMENT($,#889);
#895= IFCBEAM('13c6Dt0000F34nCpGmDZ0s',#65,'BEAM','L4X4X3/8',$,#892,#872,$);
#914= IFCSTRUCTURALPROFILEPROPERTIES('L4X4X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#915= IFCCARTESIANPOINT((0.,0.));
#919= IFCDIRECTION((1.,0.));
#923= IFCAXIS2PLACEMENT2D(#915,#919);
#926= IFCLSHAPEPROFILEDEF(.AREA.,$,#923,101.6,101.60025,9.5249996,9.525,$,$,$,$);
#927= IFCDIRECTION((0.,0.,-1.));
#931= IFCDIRECTION((-1.,0.,0.));
#935= IFCAXIS2PLACEMENT3D(#21,#931,#33);
#938= IFCEXTRUDEDAREASOLID(#926,#935,#927,330.2);
#941= IFCCARTESIANPOINT((0.,-50.799999,-50.800125));
#945= IFCBOUNDINGBOX(#941,330.2,101.6,101.60025);
#948= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#938));
#954= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#945));
#960= IFCPRODUCTDEFINITIONSHAPE($,$,(#948,#954));
#964= IFCCARTESIANPOINT((4801.108,7564.4494,495.301));
#968= IFCDIRECTION((0.,-1.,0.));
#972= IFCDIRECTION((0.,0.,-1.));
#976= IFCAXIS2PLACEMENT3D(#964,#968,#972);
#979= IFCLOCALPLACEMENT($,#976);
#982= IFCBEAM('13c6Dt00006J4nCpGmDZ0s',#65,'BEAM','L4X4X3/8',$,#979,#960,$);
#1001= IFCPOLYLINE((#1006,#1010,#1014,#1018,#1022,#1026));
#1006= IFCCARTESIANPOINT((0.,0.));
#1010= IFCCARTESIANPOINT((-0.001,486.84845));
#1014= IFCCARTESIANPOINT((369.07135,486.84845));
#1018= IFCCARTESIANPOINT((512.75543,343.16437));
#1022= IFCCARTESIANPOINT((512.75543,0.));
#1026= IFCCARTESIANPOINT((0.,0.));
#1030= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL9.5',#1001);
#1031= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#1034= IFCEXTRUDEDAREASOLID(#1030,#1031,#33,9.5);
#1037= IFCCARTESIANPOINT((-0.0010532137,0.,-4.75));
#1041= IFCBOUNDINGBOX(#1037,486.8495,512.75614,9.5);
#1044= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1034));
#1050= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1041));
#1056= IFCPRODUCTDEFINITIONSHAPE($,$,(#1044,#1050));
#1060= IFCCARTESIANPOINT((4763.008,7615.262,12.701));
#1064= IFCDIRECTION((0.,1.,0.));
#1068= IFCDIRECTION((0.,0.,1.));
#1072= IFCAXIS2PLACEMENT3D(#1060,#1064,#1068);
#1075= IFCLOCALPLACEMENT($,#1072);
#1078= IFCPLATE('13c6Dt00005J4nCpGmDZ0s',#65,'PLATE','PL9.5',$,#1075,#1056,$);
#1097= IFCCARTESIANPOINT((0.,0.));
#1101= IFCDIRECTION((1.,0.));
#1105= IFCAXIS2PLACEMENT2D(#1097,#1101);
#1108= IFCRECTANGLEHOLLOWPROFILEDEF(.AREA.,$,#1105,152.39999,152.39999,9.5249996,9.5250004,19.05);
#1109= IFCDIRECTION((0.,0.,-1.));
#1113= IFCDIRECTION((-1.,0.,0.));
#1117= IFCAXIS2PLACEMENT3D(#1130,#1113,#33);
#1120= IFCEXTRUDEDAREASOLID(#1108,#1117,#1109,2177.4228);
#1123= IFCCARTESIANPOINT((-1.2141754E-10,-76.199997,-76.199997));
#1127= IFCBOUNDINGBOX(#1123,677.4228,152.39999,152.39999);
#1130= IFCCARTESIANPOINT((-1500.,0.,0.));
#1135= IFCCARTESIANPOINT((-1.0913936E-10,-9.0949470E-12,4.7379999));
#1139= IFCDIRECTION((-1.,-1.6052583E-13,0.));
#1143= IFCDIRECTION((-1.6052583E-13,1.,0.));
#1147= IFCAXIS2PLACEMENT3D(#1135,#1139,#1143);
#1150= IFCPLANE(#1147);
#1153= IFCHALFSPACESOLID(#1150,.F.);
#1156= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#1120,#1153);
#1159= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#1156));
#1165= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1127));
#1171= IFCPRODUCTDEFINITIONSHAPE($,$,(#1159,#1165));
#1175= IFCCARTESIANPOINT((5007.3895,7620.,282.98953));
#1179= IFCDIRECTION((0.,-1.,0.));
#1183= IFCDIRECTION((0.70710678,0.,0.70710678));
#1187= IFCAXIS2PLACEMENT3D(#1175,#1179,#1183);
#1190= IFCLOCALPLACEMENT($,#1187);
#1193= IFCBEAM('13c6Dt00003p4nCpGmDZ0r',#65,'BRACE','TS6X6X3/8',$,#1190,#1171,$);
#1212= IFCCARTESIANPOINT((5153.6546,7620.012,307.34937));
#1216= IFCDIRECTION((0.,-1.,0.));
#1220= IFCDIRECTION((-0.70710678,0.,0.70710678));
#1224= IFCAXIS2PLACEMENT3D(#1212,#1216,#1220);
#1227= IFCLOCALPLACEMENT($,#1224);
#1230= IFCCARTESIANPOINT((0.,0.));
#1234= IFCDIRECTION((1.,0.));
#1238= IFCAXIS2PLACEMENT2D(#1230,#1234);
#1241= IFCRECTANGLEPROFILEDEF(.AREA.,$,#1238,9.5,266.7);
#1242= IFCDIRECTION((0.,0.,-1.));
#1246= IFCDIRECTION((-1.,0.,0.));
#1250= IFCAXIS2PLACEMENT3D(#21,#1246,#33);
#1253= IFCEXTRUDEDAREASOLID(#1241,#1250,#1242,172.39997);
#1256= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1253));
#1263= IFCPRODUCTDEFINITIONSHAPE($,$,(#1256));
#1267= IFCOPENINGELEMENT('13c6Dt0000Ip4nCpGmDZ0s',#65,$,$,$,#1227,#1263,$);
#1288= IFCRELVOIDSELEMENT('3gz_1FcMHExhEMJzH8zl$M',#65,$,$,#1193,#1267);
#1289= IFCSTRUCTURALPROFILEPROPERTIES('TS6X6X3/8',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#1290= IFCCARTESIANPOINT((0.,0.));
#1294= IFCDIRECTION((1.,0.));
#1298= IFCAXIS2PLACEMENT2D(#1290,#1294);
#1301= IFCISHAPEPROFILEDEF(.AREA.,$,#1298,254.88901,356.616,12.7,18.288,21.3995);
#1302= IFCDIRECTION((0.,0.,-1.));
#1306= IFCDIRECTION((-1.,0.,0.));
#1310= IFCAXIS2PLACEMENT3D(#1323,#1306,#33);
#1313= IFCEXTRUDEDAREASOLID(#1301,#1310,#1302,2768.2595);
#1316= IFCCARTESIANPOINT((0.,-178.308,-127.4445));
#1320= IFCBOUNDINGBOX(#1316,1268.2595,356.616,254.88901);
#1323= IFCCARTESIANPOINT((-1500.,0.,0.));
#1327= IFCCARTESIANPOINT((0.,278.308,0.));
#1331= IFCDIRECTION((-1.,0.,0.));
#1335= IFCDIRECTION((0.,0.,1.));
#1339= IFCAXIS2PLACEMENT3D(#1327,#1331,#1335);
#1342= IFCPLANE(#1339);
#1345= IFCHALFSPACESOLID(#1342,.F.);
#1348= IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#1313,#1345);
#1351= IFCSHAPEREPRESENTATION(#40,'Body','Clipping',(#1348));
#1357= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1320));
#1363= IFCPRODUCTDEFINITIONSHAPE($,$,(#1351,#1357));
#1367= IFCCARTESIANPOINT((4763.008,7620.,-178.308));
#1371= IFCDIRECTION((0.,-1.,0.));
#1375= IFCDIRECTION((1.,0.,-7.2832534E-15));
#1379= IFCAXIS2PLACEMENT3D(#1367,#1371,#1375);
#1382= IFCLOCALPLACEMENT($,#1379);
#1385= IFCBEAM('13c6Dt00002p4nCpGmDZ0r',#65,'BEAM','W14X68',$,#1382,#1363,$);
#1405= IFCSTRUCTURALPROFILEPROPERTIES('W14X68',$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$,$);
#1406= IFCCARTESIANPOINT((0.,0.));
#1410= IFCDIRECTION((1.,0.));
#1414= IFCAXIS2PLACEMENT2D(#1406,#1410);
#1417= IFCISHAPEPROFILEDEF(.AREA.,$,#1414,254.88901,356.616,12.7,18.288,21.3995);
#1418= IFCDIRECTION((0.,0.,-1.));
#1422= IFCDIRECTION((-1.,0.,0.));
#1426= IFCAXIS2PLACEMENT3D(#21,#1422,#33);
#1429= IFCEXTRUDEDAREASOLID(#1417,#1426,#1418,1524.);
#1432= IFCCARTESIANPOINT((0.,-178.308,-127.4445));
#1436= IFCBOUNDINGBOX(#1432,1524.,356.616,254.88901);
#1439= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1429));
#1445= IFCSHAPEREPRESENTATION(#43,$,'BoundingBox',(#1436));
#1451= IFCPRODUCTDEFINITIONSHAPE($,$,(#1439,#1445));
#1455= IFCCARTESIANPOINT((4572.,7620.,-609.6));
#1459= IFCDIRECTION((0.,-1.,0.));
#1463= IFCDIRECTION((7.1613756E-15,0.,1.));
#1467= IFCAXIS2PLACEMENT3D(#1455,#1459,#1463);
#1470= IFCLOCALPLACEMENT($,#1467);
#1473= IFCCOLUMN('13c6Dt00001p4nCpGmDZ0q',#65,'COLUMN','W14X68',$,#1470,#1451,$);
#1492= IFCCARTESIANPOINT((4661.154,7490.05,308.83104));
#1496= IFCDIRECTION((0.,1.,0.));
#1500= IFCDIRECTION((-1.,0.,7.0134555E-15));
#1504= IFCAXIS2PLACEMENT3D(#1492,#1496,#1500);
#1507= IFCLOCALPLACEMENT($,#1504);
#1510= IFCPOLYLINE((#1514,#1519,#1523,#1527,#1531,#1535,#1539,#1543,#1547,#1551,#1555,#1559,#1563,#1567,#1571,#1575,#1579,#1583,#1587,#1591,#1595,#1599,#1603,#1607,#1611,#1615,#1619,#1623,#1627,#1631,#1635,#1639,#1643,#1648,#1652,#1656,#1660));
#1514= IFCCARTESIANPOINT((0.38429439,16.098194));
#1519= IFCCARTESIANPOINT((0.,20.));
#1523= IFCCARTESIANPOINT((0.,336.616));
#1527= IFCCARTESIANPOINT((0.38429439,340.5178));
#1531= IFCCARTESIANPOINT((1.5224093,344.26967));
#1535= IFCCARTESIANPOINT((3.3706078,347.7274));
#1539= IFCCARTESIANPOINT((5.8578644,350.75813));
#1543= IFCCARTESIANPOINT((8.8885953,353.24539));
#1547= IFCCARTESIANPOINT((12.346331,355.09359));
#1551= IFCCARTESIANPOINT((16.098194,356.2317));
#1555= IFCCARTESIANPOINT((20.,356.616));
#1559= IFCCARTESIANPOINT((158.308,356.616));
#1563= IFCCARTESIANPOINT((162.20981,356.2317));
#1567= IFCCARTESIANPOINT((165.96167,355.09359));
#1571= IFCCARTESIANPOINT((169.4194,353.24539));
#1575= IFCCARTESIANPOINT((172.45013,350.75813));
#1579= IFCCARTESIANPOINT((174.93739,347.7274));
#1583= IFCCARTESIANPOINT((176.78559,344.26967));
#1587= IFCCARTESIANPOINT((177.9237,340.5178));
#1591= IFCCARTESIANPOINT((178.308,336.616));
#1595= IFCCARTESIANPOINT((178.308,20.));
#1599= IFCCARTESIANPOINT((177.9237,16.098194));
#1603= IFCCARTESIANPOINT((176.78559,12.346331));
#1607= IFCCARTESIANPOINT((174.93739,8.8885953));
#1611= IFCCARTESIANPOINT((172.45013,5.8578644));
#1615= IFCCARTESIANPOINT((169.4194,3.3706078));
#1619= IFCCARTESIANPOINT((165.96167,1.5224093));
#1623= IFCCARTESIANPOINT((162.20981,0.38429439));
#1627= IFCCARTESIANPOINT((158.308,0.));
#1631= IFCCARTESIANPOINT((20.,0.));
#1635= IFCCARTESIANPOINT((16.098194,0.38429439));
#1639= IFCCARTESIANPOINT((12.346331,1.5224093));
#1643= IFCCARTESIANPOINT((8.8885953,3.3706078));
#1648= IFCCARTESIANPOINT((5.8578644,5.8578644));
#1652= IFCCARTESIANPOINT((3.3706078,8.8885953));
#1656= IFCCARTESIANPOINT((1.5224093,12.346331));
#1660= IFCCARTESIANPOINT((0.38429439,16.098194));
#1664= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PL259.9',#1510);
#1665= IFCAXIS2PLACEMENT3D(#21,#33,#25);
#1668= IFCEXTRUDEDAREASOLID(#1664,#1665,#33,259.9);
#1671= IFCSHAPEREPRESENTATION(#40,'Body','SweptSolid',(#1668));
#1677= IFCPRODUCTDEFINITIONSHAPE($,$,(#1671));
#1681= IFCOPENINGELEMENT('13c6Dt0002m34nCpGmDZ4o',#65,$,$,$,#1507,#1677,$);
#1702= IFCRELVOIDSELEMENT('2WI1gpRhj1dwB5Pft5vGvO',#65,$,$,#1473,#1681);
#1703= IFCRELASSOCIATESMATERIAL('1GOaWfy957kRCtp2R28$a9',#9,$,$,(#1193),#59);
#1705= IFCRELASSOCIATESMATERIAL('3Vqwp2DmT3TeKWHZyFzO7N',#9,$,$,(#1473,#1385,#633),#56);
#1707= IFCRELASSOCIATESMATERIAL('0SMrmHf_5BRAYDVih2VNTN',#9,$,$,(#1078,#982,#895,#808,#720,#521,#434,#350,#268,#174),#53);
#1709= IFCRELASSOCIATESPROFILEPROPERTIES('2mzP0x3pj2GwUm7Wy7Xfs0',#9,$,$,(#633),#652,$,$);
#1711= IFCRELASSOCIATESPROFILEPROPERTIES('1iUJLoCsfD6ug6keUDRGVW',#9,$,$,(#808),#739,$,$);
#1713= IFCRELASSOCIATESPROFILEPROPERTIES('1LidMEK_vEQuG8Js1MD1ND',#9,$,$,(#895,#982),#914,$,$);
#1715= IFCRELASSOCIATESPROFILEPROPERTIES('22dp9maVb0sAxZG8F2xTIR',#9,$,$,(#1193),#1289,$,$);
#1717= IFCRELASSOCIATESPROFILEPROPERTIES('36LPEeuXn6VgX3frK_urjy',#9,$,$,(#1385),#1405,$,$);
ENDSEC;
END-ISO-10303-21;
+20 -8
View File
@@ -1,21 +1,30 @@
const fs = require('fs')
const { logger: parentLogger } = require('../observability/logging')
const TMP_RESULTS_PATH = '/tmp/import_result.json'
const { parseAndCreateCommit } = require('./index')
const { parseAndCreateCommitFactory } = require('./index')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const getDbClients = require('../knex')
async function main() {
const cmdArgs = process.argv.slice(2)
const [filePath, userId, streamId, branchName, commitMessage, fileId] = cmdArgs
const [
filePath,
tmpResultsPath,
userId,
streamId,
branchName,
commitMessage,
fileId,
branchId,
regionName
] = cmdArgs
const logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId, filePath }),
parentLogger.child({ streamId, branchName, userId, fileId, branchId, filePath }),
'ifc'
)
logger.info('ARGV: ', filePath, userId, streamId, branchName, commitMessage)
logger.info({ commitMessage }, 'IFC parser started.')
const data = fs.readFileSync(filePath)
@@ -25,6 +34,7 @@ async function main() {
userId,
message: commitMessage || ' Imported file',
fileId,
branchId,
logger
}
if (branchName) ifcInput.branchName = branchName
@@ -34,8 +44,10 @@ async function main() {
error: 'Unknown error'
}
const dbClients = await getDbClients()
const knex = dbClients[regionName].public
try {
const commitId = await parseAndCreateCommit(ifcInput)
const commitId = await parseAndCreateCommitFactory({ db: knex })(ifcInput)
output = {
success: true,
commitId
@@ -48,7 +60,7 @@ async function main() {
}
}
fs.writeFileSync(TMP_RESULTS_PATH, JSON.stringify(output))
fs.writeFileSync(tmpResultsPath, JSON.stringify(output))
process.exit(0)
}
+73 -75
View File
@@ -1,86 +1,84 @@
const { performance } = require('perf_hooks')
const { fetch } = require('undici')
const Parser = require('./parser_v2')
const ServerAPI = require('./api.js')
const Parser = require('./parser')
const ServerAPI = require('../src/api.js')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
async function parseAndCreateCommit({
data,
streamId,
branchName = 'uploads',
userId,
message = 'Manual IFC file upload',
fileId,
logger
}) {
if (!logger) {
logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId }),
'ifc'
const parseAndCreateCommitFactory =
({ db }) =>
async ({
data,
streamId,
branchName = 'uploads',
userId,
message = 'Manual IFC file upload',
fileId,
branchId,
logger
}) => {
if (!logger) {
logger = Observability.extendLoggerComponent(
parentLogger.child({ streamId, branchName, userId, fileId }),
'ifc'
)
}
const serverApi = new ServerAPI({ db, streamId, logger })
const myParser = new Parser({ serverApi, fileId, logger })
const start = performance.now()
const { id, tCount } = await myParser.parse(data)
logger = logger.child({ objectId: id })
const end = performance.now()
logger.info(
{
fileProcessingDurationMs: (end - start).toFixed(2)
},
'Total processing time V2: {fileProcessingDurationMs}ms'
)
}
const serverApi = new ServerAPI({ streamId, logger })
const myParser = new Parser({ serverApi, fileId, logger })
const start = performance.now()
const { id, tCount } = await myParser.parse(data)
logger = logger.child({ objectId: id })
const end = performance.now()
logger.info(
{
fileProcessingDurationMs: (end - start).toFixed(2)
},
'Total processing time V2: {fileProcessingDurationMs}ms'
)
const commit = {
streamId,
branchName,
objectId: id,
message,
sourceApplication: 'IFC',
totalChildrenCount: tCount
}
const branch = await serverApi.getBranchByNameAndStreamId({
streamId,
name: branchName
})
if (!branch) {
logger.info("Branch '{branchName}' not found, creating it.")
await serverApi.createBranch({
name: branchName,
const commit = {
streamId,
description: branchName === 'uploads' ? 'File upload branch' : null,
authorId: userId
branchName,
objectId: id,
message,
sourceApplication: 'IFC',
totalChildrenCount: tCount
}
if (!branchId) {
logger.info("Branch '{branchName}' not found, creating it.")
await serverApi.createBranch({
name: branchName,
streamId,
description: branchName === 'uploads' ? 'File upload branch' : null,
authorId: userId
})
}
const userToken = process.env.USER_TOKEN
const serverBaseUrl = process.env.SPECKLE_SERVER_URL || 'http://127.0.0.1:3000'
logger.info(`Creating commit for object ({objectId}), with message "${message}"`)
const response = await fetch(serverBaseUrl + '/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`
},
body: JSON.stringify({
query:
'mutation createCommit( $myCommitInput: CommitCreateInput!) { commitCreate( commit: $myCommitInput ) }',
variables: {
myCommitInput: commit
}
})
})
const json = await response.json()
logger.info(json, 'Commit created')
return json.data.commitCreate
}
const userToken = process.env.USER_TOKEN
const serverBaseUrl = process.env.SPECKLE_SERVER_URL || 'http://127.0.0.1:3000'
logger.info(`Creating commit for object ({objectId}), with message "${message}"`)
const response = await fetch(serverBaseUrl + '/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`
},
body: JSON.stringify({
query:
'mutation createCommit( $myCommitInput: CommitCreateInput!) { commitCreate( commit: $myCommitInput ) }',
variables: {
myCommitInput: commit
}
})
})
const json = await response.json()
logger.info(json, 'Commit created')
return json.data.commitCreate
}
module.exports = { parseAndCreateCommit }
module.exports = { parseAndCreateCommitFactory }
+384 -317
View File
@@ -1,328 +1,403 @@
/* eslint-disable camelcase */
const { performance } = require('perf_hooks')
const WebIFC = require('web-ifc/web-ifc-api-node')
const { logger } = require('../observability/logging.js')
const ServerAPI = require('./api.js')
const {
getHash,
IfcElements,
PropNames,
GeometryTypes,
IfcTypesMap
} = require('./utils')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
module.exports = class IFCParser {
constructor({ serverApi, logger }) {
this.api = new WebIFC.IfcAPI()
this.serverApi = serverApi || new ServerAPI({ logger })
constructor({ serverApi, fileId, logger }) {
this.ifcapi = new WebIFC.IfcAPI()
this.ifcapi.SetWasmPath('./', false)
this.serverApi = serverApi
this.fileId = fileId
this.logger =
logger ||
Observability.extendLoggerComponent(parentLogger.child({ fileId }), 'ifc')
}
async parse(data) {
if (this.api.wasmModule === undefined) await this.api.Init()
this.modelId = this.api.OpenModel(data, {
COORDINATE_TO_ORIGIN: true,
await this.ifcapi.Init()
this.modelId = this.ifcapi.OpenModel(new Uint8Array(data), {
USE_FAST_BOOLS: true
})
this.projectId = this.api.GetLineIDsWithType(this.modelId, WebIFC.IFCPROJECT).get(0)
this.startTime = performance.now()
this.project = this.api.GetLine(this.modelId, this.projectId, true)
this.project.__closure = {}
// prepoulate types
this.types = await this.getAllTypesOfModel()
this.cache = {}
this.closureCache = {}
// prime caches for property sets and their relating objects, as well as,
// most importantly, all the properties.
const { psetLines, psetRelations, properties } = await this.getAllProps()
this.psetLines = psetLines
this.psetRelations = psetRelations
this.properties = properties
// Steps: create and store in speckle all the geometries (meshes) from this project and store them
// as reference objects in this.productGeo
this.productGeo = {}
await this.createGeometries()
logger.info(`Geometries created: ${Object.keys(this.productGeo).length} meshes.`)
this.propCache = {}
// Lastly, traverse the ifc project object and parse it into something friendly; as well as
// replace all its geometries with actual references to speckle meshes from the productGeo map
// This is used to pre-batch ifc objects that need to be persisted.
this.objectBucket = []
await this.traverse(this.project, true, 0)
// create and save the geometries; we're storing only references locally.
this.geometryReferences = await this.createAndSaveMeshes()
const id = await this.serverApi.saveObject(this.project)
return { id, tCount: Object.keys(this.project.__closure).length }
// create and save the spatial tree, populating both properties and geometry references
// where appropriate
this.spatialNodeCount = 0
const structure = await this.createSpatialStructure()
return { id: structure.id, tCount: structure.closureLen }
}
async createGeometries() {
this.rawGeo = this.api.LoadAllGeometry(this.modelId)
async createSpatialStructure() {
const chunks = await this.getSpatialTreeChunks()
const allProjectLines = await this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCPROJECT
)
const project = {
expressID: allProjectLines.get(0),
type: 'IFCPROJECT',
// eslint-disable-next-line camelcase
speckle_type: 'Base',
elements: []
}
for (let i = 0; i < this.rawGeo.size(); i++) {
const mesh = this.rawGeo.get(i)
const prodId = mesh.expressID
this.productGeo[prodId] = []
await this.populateSpatialNode(project, chunks, [], 0)
for (let j = 0; j < mesh.geometries.size(); j++) {
const placedGeom = mesh.geometries.get(j)
const geom = this.api.GetGeometry(this.modelId, placedGeom.geometryExpressID)
this.endTime = performance.now()
project.parseTime = (this.endTime - this.startTime).toFixed(2) + 'ms'
project.fileId = this.fileId
const matrix = placedGeom.flatTransformation
const raw = {
color: geom.color, // NOTE: material: x, y, z = rgb, w = opacity
vertices: this.api.GetVertexArray(
geom.GetVertexData(),
geom.GetVertexDataSize()
),
indices: this.api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize())
// Last save to db call, empty the last bucket
if (this.objectBucket.length !== 0) {
await this.flushObjectBucket()
}
return project
}
async populateSpatialNode(node, chunks, closures, depth) {
depth++
this.logger.debug(`${this.spatialNodeCount++} nodes generated.`)
closures.push([])
await this.getChildren(node, chunks, PropNames.aggregates, closures, depth)
await this.getChildren(node, chunks, PropNames.spatial, closures, depth)
node.closure = [...new Set(closures.pop())]
// get geometry, set displayValue
// add geometry ids to closure
if (
this.geometryReferences[node.expressID] &&
this.geometryReferences[node.expressID].length !== 0
) {
node['@displayValue'] = this.geometryReferences[node.expressID]
node.closure.push(
...this.geometryReferences[node.expressID].map((ref) => ref.referencedId)
)
}
// node.closureLen = node.closure.length
node.__closure = this.formatClosure(node.closure)
node.id = getHash(node)
// Save to db
this.objectBucket.push(node)
if (this.objectBucket.length > 3000) {
await this.flushObjectBucket()
}
// remove project level node closure
if (depth === 1) {
delete node.closure
}
return node.id
}
async flushObjectBucket() {
if (this.objectBucket.length === 0) return
await this.serverApi.saveObjectBatch(this.objectBucket)
this.objectBucket = []
}
formatClosure(idsArray) {
const cl = {}
for (const id of idsArray) cl[id] = 1
return cl
}
async getChildren(node, chunks, propName, closures) {
const children = chunks[node.expressID]
if (!children) return
const prop = propName.key
const nodes = []
for (let i = 0; i < children.length; i++) {
const child = children[i]
let cnode = this.createNode(child)
cnode = { ...cnode, ...(await this.getItemProperties(cnode.expressID)) }
cnode.id = await this.populateSpatialNode(cnode, chunks, closures)
for (const closure of closures) {
closure.push(cnode.id)
if (cnode['closure'].length > 30_000)
for (const id of cnode['closure']) closure.push(id)
else closure.push(...cnode['closure']) // can stack overflow for large arguments
}
delete cnode.closure
nodes.push(cnode)
}
node[prop] = nodes.map((node) => ({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: node.id
}))
}
async getItemProperties(id) {
if (this.propCache[id]) return this.propCache[id]
let props = {}
const directProps = this.properties[id.toString()]
props = { ...directProps }
const psetIds = []
for (let i = 0; i < this.psetRelations.length; i++) {
if (this.psetRelations[i].includes(id))
psetIds.push(this.psetLines.get(i).toString())
}
const rawPsetIds = psetIds.map((id) =>
this.properties[id].RelatingPropertyDefinition.toString()
)
const rawPsets = rawPsetIds.map((id) => this.properties[id])
for (const pset of rawPsets) {
props[pset.Name] = this.unpackPsetOrComplexProp(pset)
}
this.propCache[id] = props
return props
}
unpackPsetOrComplexProp(pset) {
const parsed = {}
if (!pset.HasProperties || !Array.isArray(pset.HasProperties)) return parsed
for (const id of pset.HasProperties) {
const value = this.properties[id.toString()]
if (value?.type === 'IFCCOMPLEXPROPERTY') {
parsed[value.Name] = this.unpackPsetOrComplexProp(value)
} else if (value?.type === 'IFCPROPERTYSINGLEVALUE') {
parsed[value.Name] = value.NominalValue
}
}
return parsed
}
async getSpatialTreeChunks() {
const treeChunks = {}
await this.getChunks(treeChunks, PropNames.aggregates)
await this.getChunks(treeChunks, PropNames.spatial)
return treeChunks
}
async getChunks(chunks, propName) {
const relation = await this.ifcapi.GetLineIDsWithType(this.modelId, propName.name)
for (let i = 0; i < relation.size(); i++) {
const rel = await this.ifcapi.GetLine(this.modelId, relation.get(i), false)
this.saveChunk(chunks, propName, rel)
}
}
saveChunk(chunks, propName, rel) {
const relating = rel[propName.relating].value
const related = rel[propName.related].map((r) => r.value)
if (chunks[relating] === undefined) {
chunks[relating] = related
} else {
chunks[relating] = chunks[relating].concat(related)
}
}
async getAllTypesOfModel() {
const result = {}
const elements = Object.keys(IfcElements).map((e) => parseInt(e))
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const lines = await this.ifcapi.GetLineIDsWithType(this.modelId, element)
const size = lines.size()
for (let i = 0; i < size; i++) result[lines.get(i)] = element
}
return result
}
async getAllProps() {
const psetLines = this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCRELDEFINESBYPROPERTIES
)
const psetRelations = []
const properties = {}
const geometryIds = await this.getAllGeometriesIds()
const allLinesIDs = await this.ifcapi.GetAllLines(this.modelId)
const allLinesCount = allLinesIDs.size()
for (let i = 0; i < allLinesCount; i++) {
this.logger.debug(`${((i / allLinesCount) * 100).toFixed(3)}% props.`)
const id = allLinesIDs.get(i)
if (!geometryIds.has(id)) {
const props = await this.getItemProperty(id)
if (props) {
if (props.type === 'IFCRELDEFINESBYPROPERTIES' && props.RelatedObjects) {
psetRelations.push(props.RelatedObjects)
}
properties[id] = props
}
}
}
const { vertices } = this.extractVertexData(raw.vertices)
return { psetLines, psetRelations, properties }
}
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
async getItemProperty(id) {
try {
const props = await this.ifcapi.GetLine(this.modelId, id)
if (props.type) {
props.type = IfcTypesMap[props.type]
}
this.inPlaceFormatItemProperties(props)
return props
} catch (e) {
this.logger.error(e, `There was an issue getting props of id ${id}`)
}
}
// Since all faces are triangles, we must add a `0` before each group of 3.
const spcklFaces = []
for (let i = 0; i < raw.indices.length; i++) {
if (i % 3 === 0) spcklFaces.push(0)
spcklFaces.push(raw.indices[i])
}
inPlaceFormatItemProperties(props) {
Object.keys(props).forEach((key) => {
const value = props[key]
if (value && value.value !== undefined) props[key] = value.value
else if (Array.isArray(value))
props[key] = value.map((item) => {
if (item && item.value) return item.value
return item
})
})
}
// Create a proper Speckle Mesh
const spcklMesh = {
createNode(id) {
const typeName = this.getNodeType(id)
return {
// eslint-disable-next-line camelcase
speckle_type: typeName,
expressID: id,
type: typeName,
elements: [],
properties: null
}
}
getNodeType(id) {
const typeID = this.types[id]
return IfcElements[typeID]
}
async getAllGeometriesIds() {
const geometriesIds = new Set()
const geomTypesArray = Array.from(GeometryTypes)
for (let i = 0; i < geomTypesArray.length; i++) {
const category = geomTypesArray[i]
const ids = await this.ifcapi.GetLineIDsWithType(this.modelId, category)
const idsSize = ids.size()
for (let j = 0; j < idsSize; j++) {
geometriesIds.add(ids.get(j))
}
}
this.geometryIdsCount = geometriesIds.size
return geometriesIds
}
async createAndSaveMeshes() {
const geometryReferences = {}
let count = 0
const speckleMeshes = []
this.ifcapi.StreamAllMeshes(this.modelId, async (mesh) => {
const placedGeometries = mesh.geometries
geometryReferences[mesh.expressID] = []
for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i)
const geometry = this.ifcapi.GetGeometry(
this.modelId,
placedGeometry.geometryExpressID
)
const verts = [
...this.ifcapi.GetVertexArray(
geometry.GetVertexData(),
geometry.GetVertexDataSize()
)
]
const indices = [
...this.ifcapi.GetIndexArray(
geometry.GetIndexData(),
geometry.GetIndexDataSize()
)
]
const { vertices } = this.extractVertexData(
verts,
placedGeometry.flatTransformation
)
const faces = this.extractFaces(indices)
const speckleMesh = {
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Geometry.Mesh',
units: 'm',
volume: 0,
area: 0,
faces: spcklFaces,
vertices: Array.from(vertices),
renderMaterial: placedGeom.color
? this.colorToMaterial(placedGeom.color)
// random: Math.random(), // TODO: remove, this is here just for performance benchmarking/explicit cache poisoning
vertices,
faces,
renderMaterial: placedGeometry.color
? this.colorToMaterial(placedGeometry.color)
: null
}
const id = await this.serverApi.saveObject(spcklMesh)
const ref = { speckle_type: 'reference', referencedId: id }
this.productGeo[prodId].push(ref)
speckleMesh.id = getHash(speckleMesh)
// Note: the web-ifc api disposes of the data post callback, and doesn't know that it's async;
// we cannot and should not await things in here. I'm not entirely sure what's going on :)
// await this.serverApi.saveObject(speckleMesh)
speckleMeshes.push(speckleMesh)
geometryReferences[mesh.expressID].push({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: speckleMesh.id
})
this.logger.debug(`${(count++).toFixed(3)} geoms generated.`)
}
}
})
await this.serverApi.saveObjectBatch(speckleMeshes)
return geometryReferences
}
async traverse(
element,
recursive = true,
depth = 0,
specialTypes = [
{ type: 'IfcProject', key: 'Name' },
{ type: 'IfcBuilding', key: 'Name' },
{ type: 'IfcSite', key: 'Name' }
]
) {
// Fast exit if null/undefined
if (!element) return
// If array, traverse all items in it.
if (Array.isArray(element)) {
return await Promise.all(
element.map(
async (el) => await this.traverse(el, recursive, depth + 1, specialTypes)
)
)
}
// If it has no expressID, its either a simple type or a { type, value } object.
if (!element.expressID) {
return await Promise.resolve(
element.value !== null && element.value !== undefined ? element.value : element
)
}
if (this.cache[element.expressID.toString()])
return this.cache[element.expressID.toString()]
// If you got here -> It's an IFC Element: create base object, upload and return ref.
// logger.debug( `Traversing element ${element.expressID}; Recurse: ${recursive}; Stack ${depth}` )
// Traverse all key/value pairs first.
for (const key of Object.keys(element)) {
element[key] = await this.traverse(
element[key],
recursive,
depth + 1,
specialTypes
)
}
// Assign speckle_type and empty closure table.
element.speckle_type = element.constructor.name
element.__closure = {}
// Find spatial children and assign to element
const spatialChildrenIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELAGGREGATES,
'RelatingObject',
'RelatedObjects'
)
if (spatialChildrenIds.length > 0)
element.rawSpatialChildren = spatialChildrenIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find children and populate element
const childrenIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE,
'RelatingStructure',
'RelatedElements'
)
if (childrenIds.length > 0)
element.rawChildren = childrenIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find related property sets
const psetsIds = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELDEFINESBYPROPERTIES,
'RelatingPropertyDefinition',
'RelatedObjects'
)
if (psetsIds.length > 0)
element.rawPsets = psetsIds.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Find related type properties
const typePropsId = this.getAllRelatedItemsOfType(
element.expressID,
WebIFC.IFCRELDEFINESBYTYPE,
'RelatingType',
'RelatedObjects'
)
if (typePropsId.length > 0)
element.rawTypeProps = typePropsId.map((childId) =>
this.api.GetLine(this.modelId, childId, true)
)
// Lookup geometry in generated geometries object
if (this.productGeo[element.expressID]) {
element['@displayValue'] = this.productGeo[element.expressID]
this.productGeo[element.expressID].forEach((ref) => {
this.project.__closure[ref.referencedId.toString()] = depth
element.__closure[ref.referencedId.toString()] = 1
})
}
const isSpecial = specialTypes.find((t) => t.type === element.speckle_type)
// Recurse all children
if (recursive) {
await this.processSubElements(
element,
'rawSpatialChildren',
'spatialChildren',
isSpecial,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawChildren',
'children',
isSpecial,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawPsets',
'propertySets',
false,
recursive,
depth,
specialTypes
)
await this.processSubElements(
element,
'rawTypeProps',
'typeProps',
false,
recursive,
depth,
specialTypes
)
if (
element.children ||
element.spatialChildren ||
element.propertySets ||
element.typeProps
) {
logger.info(
`${element.constructor.name} ${element.GlobalId}:\n\tchildren count: ${
element.children ? element.children.length : '0'
};\n\tspatial children count: ${
element.spatialChildren ? element.spatialChildren.length : '0'
};\n\tproperty sets count: ${
element.propertySets ? element.propertySets.length : 0
};\n\ttype properties: ${element.typeProps ? element.typeProps.length : 0}`
)
}
}
if (
this.productGeo[element.expressID] ||
element.spatialChildren ||
element.children
) {
const id = await this.serverApi.saveObject(element)
const ref = { speckle_type: 'reference', referencedId: id }
this.cache[element.expressID.toString()] = ref
this.closureCache[element.expressID.toString()] = element.__closure
return ref
} else {
this.cache[element.expressID.toString()] = element
this.closureCache[element.expressID.toString()] = element.__closure
return element
extractFaces(indices) {
const faces = []
for (let i = 0; i < indices.length; i++) {
if (i % 3 === 0) faces.push(0)
faces.push(indices[i])
}
return faces
}
async processSubElements(
element,
key,
newKey,
isSpecial,
recursive,
depth,
specialTypes
) {
if (element[key]) {
if (!isSpecial) element[newKey] = []
const childCount = {}
for (const child of element[key]) {
const res = await this.traverse(child, recursive, depth + 1, specialTypes)
if (res.referencedId) {
if (isSpecial) {
let name = child[isSpecial.key]
if (!name || name.length === 0) name = 'Undefined'
if (!childCount[name]) childCount[name] = 0
if (childCount[name] > 0) name += '-' + childCount[name]++
element[name] = res
} else element[newKey].push(res)
this.project.__closure[res.referencedId.toString()] = depth
element.__closure[res.referencedId.toString()] = 1
// adds to parent (this element) the child's closure tree.
if (this.closureCache[child.expressID.toString()]) {
for (const key of Object.keys(
this.closureCache[child.expressID.toString()]
)) {
element.__closure[key] =
this.closureCache[child.expressID.toString()][key] + 1
}
}
}
}
delete element[key]
}
}
// (c) https://github.com/agviegas/web-ifc-three
extractVertexData(vertexData) {
extractVertexData(vertexData, matrix) {
const vertices = []
const normals = []
let isNormalData = false
@@ -330,45 +405,37 @@ module.exports = class IFCParser {
isNormalData ? normals.push(vertexData[i]) : vertices.push(vertexData[i])
if ((i + 1) % 3 === 0) isNormalData = !isNormalData
}
// apply the transform
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
return { vertices, normals }
}
// (c) https://github.com/agviegas/web-ifc-three/blob/907e08b5673d5e1c18261a4fceade7189d6b2db7/src/IFC/PropertyManager.ts#L110
getAllRelatedItemsOfType(elementID, type, relation, relatedProperty) {
const lines = this.api.GetLineIDsWithType(this.modelId, type)
const IDs = []
for (let i = 0; i < lines.size(); i++) {
const relID = lines.get(i)
const rel = this.api.GetLine(this.modelId, relID)
const relatedItems = rel[relation]
let foundElement = false
if (Array.isArray(relatedItems)) {
const values = relatedItems.map((item) => item.value)
foundElement = values.includes(elementID)
} else foundElement = relatedItems.value === elementID
if (foundElement) {
const element = rel[relatedProperty]
if (!Array.isArray(element)) IDs.push(element.value)
else element.forEach((ele) => IDs.push(ele.value))
}
}
return IDs
}
colorToMaterial(color) {
const intColor =
(color.w << 24) + ((color.x * 255) << 16) + ((color.y * 255) << 8) + color.z * 255
return {
const intColor = Math.floor(
((color.w * 255) << 24) +
((color.x * 255) << 16) +
((color.y * 255) << 8) +
color.z * 255
)
const material = {
diffuse: intColor,
opacity: color.w,
metalness: 0,
roughness: 1,
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Other.RenderMaterial'
}
material.id = getHash(material)
return material
}
}
@@ -1,441 +0,0 @@
const { performance } = require('perf_hooks')
const WebIFC = require('web-ifc/web-ifc-api-node')
const {
getHash,
IfcElements,
PropNames,
GeometryTypes,
IfcTypesMap
} = require('./utils')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const { logger: parentLogger } = require('../observability/logging')
module.exports = class IFCParser {
constructor({ serverApi, fileId, logger }) {
this.ifcapi = new WebIFC.IfcAPI()
this.ifcapi.SetWasmPath('./', false)
this.serverApi = serverApi
this.fileId = fileId
this.logger =
logger ||
Observability.extendLoggerComponent(parentLogger.child({ fileId }), 'ifc')
}
async parse(data) {
await this.ifcapi.Init()
this.modelId = this.ifcapi.OpenModel(new Uint8Array(data), {
USE_FAST_BOOLS: true
})
this.startTime = performance.now()
// prepoulate types
this.types = await this.getAllTypesOfModel()
// prime caches for property sets and their relating objects, as well as,
// most importantly, all the properties.
const { psetLines, psetRelations, properties } = await this.getAllProps()
this.psetLines = psetLines
this.psetRelations = psetRelations
this.properties = properties
this.propCache = {}
// This is used to pre-batch ifc objects that need to be persisted.
this.objectBucket = []
// create and save the geometries; we're storing only references locally.
this.geometryReferences = await this.createAndSaveMeshes()
// create and save the spatial tree, populating both properties and geometry references
// where appropriate
this.spatialNodeCount = 0
const structure = await this.createSpatialStructure()
return { id: structure.id, tCount: structure.closureLen }
}
async createSpatialStructure() {
const chunks = await this.getSpatialTreeChunks()
const allProjectLines = await this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCPROJECT
)
const project = {
expressID: allProjectLines.get(0),
type: 'IFCPROJECT',
// eslint-disable-next-line camelcase
speckle_type: 'Base',
elements: []
}
await this.populateSpatialNode(project, chunks, [], 0)
this.endTime = performance.now()
project.parseTime = (this.endTime - this.startTime).toFixed(2) + 'ms'
project.fileId = this.fileId
// Last save to db call, empty the last bucket
if (this.objectBucket.length !== 0) {
await this.flushObjectBucket()
}
return project
}
async populateSpatialNode(node, chunks, closures, depth) {
depth++
this.logger.debug(`${this.spatialNodeCount++} nodes generated.`)
closures.push([])
await this.getChildren(node, chunks, PropNames.aggregates, closures, depth)
await this.getChildren(node, chunks, PropNames.spatial, closures, depth)
node.closure = [...new Set(closures.pop())]
// get geometry, set displayValue
// add geometry ids to closure
if (
this.geometryReferences[node.expressID] &&
this.geometryReferences[node.expressID].length !== 0
) {
node['@displayValue'] = this.geometryReferences[node.expressID]
node.closure.push(
...this.geometryReferences[node.expressID].map((ref) => ref.referencedId)
)
}
// node.closureLen = node.closure.length
node.__closure = this.formatClosure(node.closure)
node.id = getHash(node)
// Save to db
this.objectBucket.push(node)
if (this.objectBucket.length > 3000) {
await this.flushObjectBucket()
}
// remove project level node closure
if (depth === 1) {
delete node.closure
}
return node.id
}
async flushObjectBucket() {
if (this.objectBucket.length === 0) return
await this.serverApi.saveObjectBatch(this.objectBucket)
this.objectBucket = []
}
formatClosure(idsArray) {
const cl = {}
for (const id of idsArray) cl[id] = 1
return cl
}
async getChildren(node, chunks, propName, closures) {
const children = chunks[node.expressID]
if (!children) return
const prop = propName.key
const nodes = []
for (let i = 0; i < children.length; i++) {
const child = children[i]
let cnode = this.createNode(child)
cnode = { ...cnode, ...(await this.getItemProperties(cnode.expressID)) }
cnode.id = await this.populateSpatialNode(cnode, chunks, closures)
for (const closure of closures) {
closure.push(cnode.id)
if (cnode['closure'].length > 30_000)
for (const id of cnode['closure']) closure.push(id)
else closure.push(...cnode['closure']) // can stack overflow for large arguments
}
delete cnode.closure
nodes.push(cnode)
}
node[prop] = nodes.map((node) => ({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: node.id
}))
}
async getItemProperties(id) {
if (this.propCache[id]) return this.propCache[id]
let props = {}
const directProps = this.properties[id.toString()]
props = { ...directProps }
const psetIds = []
for (let i = 0; i < this.psetRelations.length; i++) {
if (this.psetRelations[i].includes(id))
psetIds.push(this.psetLines.get(i).toString())
}
const rawPsetIds = psetIds.map((id) =>
this.properties[id].RelatingPropertyDefinition.toString()
)
const rawPsets = rawPsetIds.map((id) => this.properties[id])
for (const pset of rawPsets) {
props[pset.Name] = this.unpackPsetOrComplexProp(pset)
}
this.propCache[id] = props
return props
}
unpackPsetOrComplexProp(pset) {
const parsed = {}
if (!pset.HasProperties || !Array.isArray(pset.HasProperties)) return parsed
for (const id of pset.HasProperties) {
const value = this.properties[id.toString()]
if (value?.type === 'IFCCOMPLEXPROPERTY') {
parsed[value.Name] = this.unpackPsetOrComplexProp(value)
} else if (value?.type === 'IFCPROPERTYSINGLEVALUE') {
parsed[value.Name] = value.NominalValue
}
}
return parsed
}
async getSpatialTreeChunks() {
const treeChunks = {}
await this.getChunks(treeChunks, PropNames.aggregates)
await this.getChunks(treeChunks, PropNames.spatial)
return treeChunks
}
async getChunks(chunks, propName) {
const relation = await this.ifcapi.GetLineIDsWithType(this.modelId, propName.name)
for (let i = 0; i < relation.size(); i++) {
const rel = await this.ifcapi.GetLine(this.modelId, relation.get(i), false)
this.saveChunk(chunks, propName, rel)
}
}
saveChunk(chunks, propName, rel) {
const relating = rel[propName.relating].value
const related = rel[propName.related].map((r) => r.value)
if (chunks[relating] === undefined) {
chunks[relating] = related
} else {
chunks[relating] = chunks[relating].concat(related)
}
}
async getAllTypesOfModel() {
const result = {}
const elements = Object.keys(IfcElements).map((e) => parseInt(e))
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const lines = await this.ifcapi.GetLineIDsWithType(this.modelId, element)
const size = lines.size()
for (let i = 0; i < size; i++) result[lines.get(i)] = element
}
return result
}
async getAllProps() {
const psetLines = this.ifcapi.GetLineIDsWithType(
this.modelId,
WebIFC.IFCRELDEFINESBYPROPERTIES
)
const psetRelations = []
const properties = {}
const geometryIds = await this.getAllGeometriesIds()
const allLinesIDs = await this.ifcapi.GetAllLines(this.modelId)
const allLinesCount = allLinesIDs.size()
for (let i = 0; i < allLinesCount; i++) {
this.logger.debug(`${((i / allLinesCount) * 100).toFixed(3)}% props.`)
const id = allLinesIDs.get(i)
if (!geometryIds.has(id)) {
const props = await this.getItemProperty(id)
if (props) {
if (props.type === 'IFCRELDEFINESBYPROPERTIES' && props.RelatedObjects) {
psetRelations.push(props.RelatedObjects)
}
properties[id] = props
}
}
}
return { psetLines, psetRelations, properties }
}
async getItemProperty(id) {
try {
const props = await this.ifcapi.GetLine(this.modelId, id)
if (props.type) {
props.type = IfcTypesMap[props.type]
}
this.inPlaceFormatItemProperties(props)
return props
} catch (e) {
this.logger.error(e, `There was an issue getting props of id ${id}`)
}
}
inPlaceFormatItemProperties(props) {
Object.keys(props).forEach((key) => {
const value = props[key]
if (value && value.value !== undefined) props[key] = value.value
else if (Array.isArray(value))
props[key] = value.map((item) => {
if (item && item.value) return item.value
return item
})
})
}
createNode(id) {
const typeName = this.getNodeType(id)
return {
// eslint-disable-next-line camelcase
speckle_type: typeName,
expressID: id,
type: typeName,
elements: [],
properties: null
}
}
getNodeType(id) {
const typeID = this.types[id]
return IfcElements[typeID]
}
async getAllGeometriesIds() {
const geometriesIds = new Set()
const geomTypesArray = Array.from(GeometryTypes)
for (let i = 0; i < geomTypesArray.length; i++) {
const category = geomTypesArray[i]
const ids = await this.ifcapi.GetLineIDsWithType(this.modelId, category)
const idsSize = ids.size()
for (let j = 0; j < idsSize; j++) {
geometriesIds.add(ids.get(j))
}
}
this.geometryIdsCount = geometriesIds.size
return geometriesIds
}
async createAndSaveMeshes() {
const geometryReferences = {}
let count = 0
const speckleMeshes = []
this.ifcapi.StreamAllMeshes(this.modelId, async (mesh) => {
const placedGeometries = mesh.geometries
geometryReferences[mesh.expressID] = []
for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i)
const geometry = this.ifcapi.GetGeometry(
this.modelId,
placedGeometry.geometryExpressID
)
const verts = [
...this.ifcapi.GetVertexArray(
geometry.GetVertexData(),
geometry.GetVertexDataSize()
)
]
const indices = [
...this.ifcapi.GetIndexArray(
geometry.GetIndexData(),
geometry.GetIndexDataSize()
)
]
const { vertices } = this.extractVertexData(
verts,
placedGeometry.flatTransformation
)
const faces = this.extractFaces(indices)
const speckleMesh = {
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Geometry.Mesh',
units: 'm',
volume: 0,
area: 0,
// random: Math.random(), // TODO: remove, this is here just for performance benchmarking/explicit cache poisoning
vertices,
faces,
renderMaterial: placedGeometry.color
? this.colorToMaterial(placedGeometry.color)
: null
}
speckleMesh.id = getHash(speckleMesh)
// Note: the web-ifc api disposes of the data post callback, and doesn't know that it's async;
// we cannot and should not await things in here. I'm not entirely sure what's going on :)
// await this.serverApi.saveObject(speckleMesh)
speckleMeshes.push(speckleMesh)
geometryReferences[mesh.expressID].push({
// eslint-disable-next-line camelcase
speckle_type: 'reference',
referencedId: speckleMesh.id
})
this.logger.debug(`${(count++).toFixed(3)} geoms generated.`)
}
})
await this.serverApi.saveObjectBatch(speckleMeshes)
return geometryReferences
}
extractFaces(indices) {
const faces = []
for (let i = 0; i < indices.length; i++) {
if (i % 3 === 0) faces.push(0)
faces.push(indices[i])
}
return faces
}
extractVertexData(vertexData, matrix) {
const vertices = []
const normals = []
let isNormalData = false
for (let i = 0; i < vertexData.length; i++) {
isNormalData ? normals.push(vertexData[i]) : vertices.push(vertexData[i])
if ((i + 1) % 3 === 0) isNormalData = !isNormalData
}
// apply the transform
for (let k = 0; k < vertices.length; k += 3) {
const x = vertices[k],
y = vertices[k + 1],
z = vertices[k + 2]
vertices[k] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]
vertices[k + 1] =
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * -1
vertices[k + 2] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]
}
return { vertices, normals }
}
colorToMaterial(color) {
const intColor = Math.floor(
((color.w * 255) << 24) +
((color.x * 255) << 16) +
((color.y * 255) << 8) +
color.z * 255
)
const material = {
diffuse: intColor,
opacity: color.w,
metalness: 0,
roughness: 1,
// eslint-disable-next-line camelcase
speckle_type: 'Objects.Other.RenderMaterial'
}
material.id = getHash(material)
return material
}
}
View File
+58 -15
View File
@@ -1,18 +1,61 @@
/* eslint-disable camelcase */
'use strict'
module.exports = require('knex')({
client: 'pg',
connection: {
application_name: 'speckle_fileimport_service',
connectionString:
process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@127.0.0.1/speckle'
},
pool: {
min: 0,
max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1,
acquireTimeoutMillis: 16000, //allows for 3x creation attempts plus idle time between attempts
createTimeoutMillis: 5000
const Environment = require('@speckle/shared/dist/commonjs/environment/index.js')
const {
loadMultiRegionsConfig,
configureKnexClient
} = require('@speckle/shared/dist/commonjs/environment/multiRegionConfig.js')
const { logger } = require('./observability/logging')
const { FF_WORKSPACES_MULTI_REGION_ENABLED } = Environment.getFeatureFlags()
const isDevEnv = process.env.NODE_ENV !== 'production'
let dbClients
const getDbClients = async () => {
if (dbClients) return dbClients
const maxConnections = parseInt(
process.env['POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE'] || '1'
)
const connectionAcquireTimeoutMillis = parseInt(
process.env['POSTGRES_CONNECTION_ACQUIRE_TIMEOUT_MILLIS'] || '16000'
)
const connectionCreateTimeoutMillis = parseInt(
process.env['POSTGRES_CONNECTION_CREATE_TIMEOUT_MILLIS'] || '5000'
)
const configArgs = {
migrationDirs: [],
isTestEnv: isDevEnv,
isDevOrTestEnv: isDevEnv,
logger,
maxConnections,
applicationName: 'speckle_fileimport_service',
connectionAcquireTimeoutMillis,
connectionCreateTimeoutMillis
}
// migrations are in managed in the server package
})
if (!FF_WORKSPACES_MULTI_REGION_ENABLED) {
const mainClient = configureKnexClient(
{
postgres: {
connectionUri:
process.env.PG_CONNECTION_STRING ||
'postgres://speckle:speckle@127.0.0.1/speckle'
}
},
configArgs
)
dbClients = { main: mainClient }
} else {
const configPath = process.env.MULTI_REGION_CONFIG_PATH || 'multiregion.json'
const config = await loadMultiRegionsConfig({ path: configPath })
const clients = [['main', configureKnexClient(config.main, configArgs)]]
Object.entries(config.regions).map(([key, config]) => {
clients.push([key, configureKnexClient(config, configArgs)])
})
dbClients = Object.fromEntries(clients)
}
return dbClients
}
module.exports = getDbClients
+16 -5
View File
@@ -37,7 +37,6 @@ structlog.configure(
cache_logger_on_first_use=True,
)
LOG = structlog.get_logger()
TMP_RESULTS_PATH = "/tmp/import_result.json"
DEFAULT_BRANCH = "uploads"
@@ -57,7 +56,17 @@ def convert_material(obj_mat):
def import_obj():
file_path, _, stream_id, branch_name, commit_message = sys.argv[1:]
(
file_path,
tmp_results_path,
_,
stream_id,
branch_name,
commit_message,
_,
_,
_,
) = sys.argv[1:]
LOG.info("ImportOBJ argv[1:]:%s", sys.argv[1:])
# Parse input
@@ -149,19 +158,21 @@ def import_obj():
source_application="OBJ",
)
return commit_id
return commit_id, tmp_results_path
if __name__ == "__main__":
from pathlib import Path
try:
commit_id = import_obj()
commit_id, tmp_results_path = import_obj()
if not commit_id:
raise Exception("Can't create commit")
if isinstance(commit_id, Exception):
raise commit_id
results = {"success": True, "commitId": commit_id}
except Exception as ex:
LOG.exception(ex)
results = {"success": False, "error": str(ex)}
Path(TMP_RESULTS_PATH).write_text(json.dumps(results))
Path(tmp_results_path).write_text(json.dumps(results))
+6 -4
View File
@@ -3,10 +3,10 @@
"private": true,
"version": "2.5.4",
"description": "Parse and import files of various types into a stream",
"author": "Dimitrie Stefanescu <didimitrie@gmail.com>",
"author": "Speckle Systems <hello@speckle.systems>",
"homepage": "https://github.com/specklesystems/speckle-server#readme",
"license": "SEE LICENSE IN readme.md",
"main": "index.js",
"main": "daemon.js",
"repository": {
"type": "git",
"url": "git+https://github.com/specklesystems/speckle-server.git"
@@ -26,7 +26,7 @@
"@speckle/shared": "workspace:^",
"bcrypt": "^5.0.1",
"crypto-random-string": "^3.3.1",
"knex": "^2.4.1",
"knex": "^2.5.1",
"pg": "^8.7.3",
"pino": "^8.7.0",
"pino-http": "^8.0.0",
@@ -34,7 +34,9 @@
"prom-client": "^14.0.1",
"undici": "^5.28.4",
"valid-filename": "^3.1.0",
"web-ifc": "^0.0.36"
"web-ifc": "^0.0.36",
"znv": "^0.4.0",
"zod": "^3.22.4"
},
"devDependencies": {
"cross-env": "^7.0.3",
@@ -5,17 +5,21 @@ const bcrypt = require('bcrypt')
const { chunk } = require('lodash')
const { logger: parentLogger } = require('../observability/logging')
const knex = require('../knex')
const Observability = require('@speckle/shared/dist/commonjs/observability/index.js')
const Streams = () => knex('streams')
const Branches = () => knex('branches')
const Objects = () => knex('objects')
const Closures = () => knex('object_children_closure')
const ApiTokens = () => knex('api_tokens')
const TokenScopes = () => knex('token_scopes')
const tables = (db) => ({
objects: db('objects'),
closures: db('object_children_closure'),
branches: db('branches'),
streams: db('streams'),
apiTokens: db('api_tokens'),
tokenScopes: db('token_scopes')
})
module.exports = class ServerAPI {
constructor({ streamId, logger }) {
constructor({ db, streamId, logger }) {
this.tables = tables(db)
this.db = db
this.streamId = streamId
this.isSending = false
this.buffer = []
@@ -68,10 +72,10 @@ module.exports = class ServerAPI {
totalChildrenCountByDepth
)
await Objects().insert(insertionObject).onConflict().ignore()
await this.tables.objects.insert(insertionObject).onConflict().ignore()
if (closures.length > 0) {
await Closures().insert(closures).onConflict().ignore()
await this.tables.closures.insert(closures).onConflict().ignore()
}
return insertionObject.id
@@ -123,7 +127,7 @@ module.exports = class ServerAPI {
const batches = chunk(objsToInsert, objectsBatchSize)
for (const [index, batch] of batches.entries()) {
this.prepInsertionObjectBatch(batch)
await Objects().insert(batch).onConflict().ignore()
await this.tables.objects.insert(batch).onConflict().ignore()
this.logger.info(
{
currentBatchCount: batch.length,
@@ -141,7 +145,7 @@ module.exports = class ServerAPI {
for (const [index, batch] of batches.entries()) {
this.prepInsertionClosureBatch(batch)
await Closures().insert(batch).onConflict().ignore()
await this.tables.closures.insert(batch).onConflict().ignore()
this.logger.info(
{
currentBatchCount: batch.length,
@@ -196,10 +200,10 @@ module.exports = class ServerAPI {
}
async getBranchByNameAndStreamId({ streamId, name }) {
const query = Branches()
const query = this.tables.branches
.select('*')
.where({ streamId })
.andWhere(knex.raw('LOWER(name) = ?', [name]))
.andWhere(this.db.raw('LOWER(name) = ?', [name]))
.first()
return await query
}
@@ -212,10 +216,12 @@ module.exports = class ServerAPI {
branch.name = name.toLowerCase()
branch.description = description
await Branches().returning('id').insert(branch)
await this.tables.branches.returning('id').insert(branch)
// update stream updated at
await Streams().where({ id: streamId }).update({ updatedAt: knex.fn.now() })
await this.tables.streams
.where({ id: streamId })
.update({ updatedAt: this.db.fn.now() })
return branch.id
}
@@ -244,14 +250,14 @@ module.exports = class ServerAPI {
}
const tokenScopes = scopes.map((scope) => ({ tokenId, scopeName: scope }))
await ApiTokens().insert(token)
await TokenScopes().insert(tokenScopes)
await this.tables.apiTokens.insert(token)
await this.tables.tokenScopes.insert(tokenScopes)
return { id: tokenId, token: tokenId + tokenString }
}
async revokeTokenById(tokenId) {
const delCount = await ApiTokens()
const delCount = await this.tables.apiTokens
.where({ id: tokenId.slice(0, 10) })
.del()
+121 -64
View File
@@ -1,22 +1,23 @@
'use strict'
const Environment = require('@speckle/shared/dist/commonjs/environment/index.js')
const {
initPrometheusMetrics,
metricDuration,
metricInputFileSize,
metricOperationErrors
} = require('./prometheusMetrics')
const knex = require('../knex')
const FileUploads = () => knex('file_uploads')
const getDbClients = require('../knex')
const { downloadFile } = require('./filesApi')
const fs = require('fs')
const { spawn } = require('child_process')
const ServerAPI = require('../ifc/api')
const ServerAPI = require('./api')
const objDependencies = require('./objDependencies')
const { logger } = require('../observability/logging')
const { Scopes } = require('@speckle/shared')
const { Scopes, wait } = require('@speckle/shared')
const { FF_FILEIMPORT_IFC_DOTNET_ENABLED } = Environment.getFeatureFlags()
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@@ -31,10 +32,10 @@ let TIME_LIMIT = 10 * 60 * 1000
const providedTimeLimit = parseInt(process.env.FILE_IMPORT_TIME_LIMIT_MIN)
if (providedTimeLimit) TIME_LIMIT = providedTimeLimit * 60 * 1000
async function startTask() {
async function startTask(knex) {
const { rows } = await knex.raw(`
UPDATE file_uploads
SET
SET
"convertedStatus" = 1,
"convertedLastUpdate" = NOW()
FROM (
@@ -49,15 +50,16 @@ async function startTask() {
return rows[0]
}
async function doTask(task) {
async function doTask(mainDb, regionName, taskDb, task) {
const taskId = task.id
// Mark task as started
await knex.raw(`NOTIFY file_import_started, '${task.id}'`)
await mainDb.raw(`NOTIFY file_import_started, '${task.id}'`)
let taskLogger = logger.child({ taskId })
let tempUserToken = null
let serverApi = null
let mainServerApi = null
let taskServerApi = null
let fileTypeForMetric = 'unknown'
let fileSizeForMetric = 0
@@ -67,7 +69,7 @@ async function doTask(task) {
try {
taskLogger.info("Doing task '{taskId}'.")
const info = await FileUploads().where({ id: taskId }).first()
const info = await taskDb('file_uploads').where({ id: taskId }).first()
if (!info) {
throw new Error('Internal error: DB inconsistent')
}
@@ -80,26 +82,38 @@ async function doTask(task) {
fileName: info.fileName,
fileSize: fileSizeForMetric,
userId: info.userId,
streamId: info.streamId,
branchName: info.branchName
projectId: info.streamId,
modelName: info.branchName
})
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
serverApi = new ServerAPI({ streamId: info.streamId, logger: taskLogger })
mainServerApi = new ServerAPI({
db: mainDb,
streamId: info.streamId,
logger: taskLogger
})
taskServerApi = new ServerAPI({
db: taskDb,
streamId: info.streamId,
logger: taskLogger
})
branchMetadata = {
branchName: info.branchName,
streamId: info.streamId
}
const existingBranch = await serverApi.getBranchByNameAndStreamId({
const existingBranch = await taskServerApi.getBranchByNameAndStreamId({
streamId: info.streamId,
name: info.branchName
})
if (!existingBranch) {
newBranchCreated = true
}
taskLogger = taskLogger.child({
modelId: existingBranch?.id
})
const { token } = await serverApi.createToken({
const { token } = await mainServerApi.createToken({
userId: info.userId,
name: 'temp upload token',
scopes: [Scopes.Streams.Write, Scopes.Streams.Read],
@@ -115,24 +129,47 @@ async function doTask(task) {
})
if (info.fileType.toLowerCase() === 'ifc') {
await runProcessWithTimeout(
taskLogger,
process.env['NODE_BINARY_PATH'] || 'node',
[
'--no-experimental-fetch',
'./ifc/import_file.js',
TMP_FILE_PATH,
info.userId,
info.streamId,
info.branchName,
`File upload: ${info.fileName}`,
info.id
],
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
)
if (FF_FILEIMPORT_IFC_DOTNET_ENABLED) {
await runProcessWithTimeout(
taskLogger,
process.env['DOTNET_BINARY_PATH'] || 'dotnet',
[
'/speckle-server/packages/fileimport-service/ifc-dotnet/ifc-converter.dll',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.streamId,
`File upload: ${info.fileName}`,
existingBranch?.id || '',
regionName
],
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
)
} else {
await runProcessWithTimeout(
taskLogger,
process.env['NODE_BINARY_PATH'] || 'node',
[
'--no-experimental-fetch',
'./ifc/import_file.js',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
info.streamId,
info.branchName,
`File upload: ${info.fileName}`,
info.id,
existingBranch?.id || '',
regionName
],
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
)
}
} else if (info.fileType.toLowerCase() === 'stl') {
await runProcessWithTimeout(
taskLogger,
@@ -140,10 +177,14 @@ async function doTask(task) {
[
'./stl/import_file.py',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
info.streamId,
info.branchName,
`File upload: ${info.fileName}`
`File upload: ${info.fileName}`,
info.id,
existingBranch?.id || '',
regionName
],
{
USER_TOKEN: tempUserToken
@@ -165,10 +206,14 @@ async function doTask(task) {
'-u',
'./obj/import_file.py',
TMP_FILE_PATH,
TMP_RESULTS_PATH,
info.userId,
info.streamId,
info.branchName,
`File upload: ${info.fileName}`
`File upload: ${info.fileName}`,
info.id,
existingBranch?.id || '',
regionName
],
{
USER_TOKEN: tempUserToken
@@ -185,7 +230,7 @@ async function doTask(task) {
const commitId = output.commitId
await knex.raw(
await taskDb.raw(
`
UPDATE file_uploads
SET
@@ -199,7 +244,7 @@ async function doTask(task) {
)
} catch (err) {
taskLogger.error(err)
await knex.raw(
await taskDb.raw(
`
UPDATE file_uploads
SET
@@ -208,12 +253,13 @@ async function doTask(task) {
"convertedMessage" = ?
WHERE "id" = ?
`,
[err.toString(), task.id]
// DB only accepts a varchar 255
[err.toString().substring(0, 254), task.id]
)
metricOperationErrors.labels(fileTypeForMetric).inc()
} finally {
const { streamId, branchName } = branchMetadata
await knex.raw(
await mainDb.raw(
`NOTIFY file_import_update, '${task.id}:::${streamId}:::${branchName}:::${
newBranchCreated ? 1 : 0
}'`
@@ -226,7 +272,7 @@ async function doTask(task) {
if (fs.existsSync(TMP_RESULTS_PATH)) fs.unlinkSync(TMP_RESULTS_PATH)
if (tempUserToken) {
await serverApi.revokeTokenById(tempUserToken)
await mainServerApi.revokeTokenById(tempUserToken)
}
}
@@ -305,42 +351,53 @@ function wrapLogLine(line, isErr, logger) {
logger.info({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
}
async function tick() {
if (shouldExit) {
process.exit(0)
}
try {
const task = await startTask()
fs.writeFile(HEALTHCHECK_FILE_PATH, '' + Date.now(), () => {})
if (!task) {
setTimeout(tick, 1000)
return
const doStuff = async () => {
const dbClients = await getDbClients()
const mainDb = dbClients.main.public
const dbClientsIterator = infiniteDbClientsIterator(dbClients)
while (!shouldExit) {
const [regionName, taskDb] = dbClientsIterator.next().value
try {
const task = await startTask(taskDb)
fs.writeFile(HEALTHCHECK_FILE_PATH, '' + Date.now(), () => {})
if (!task) {
await wait(1000)
continue
}
await doTask(mainDb, regionName, taskDb, task)
await wait(10)
} catch (err) {
metricOperationErrors.labels('main_loop').inc()
logger.error(err, 'Error executing task')
await wait(5000)
}
await doTask(task)
// Check for another task very soon
setTimeout(tick, 10)
} catch (err) {
metricOperationErrors.labels('main_loop').inc()
logger.error(err, 'Error executing task')
setTimeout(tick, 5000)
}
}
async function main() {
logger.info('Starting FileUploads Service...')
initPrometheusMetrics()
await initPrometheusMetrics()
process.on('SIGTERM', () => {
shouldExit = true
logger.info('Shutting down...')
})
tick()
await doStuff()
process.exit(0)
}
function* infiniteDbClientsIterator(dbClients) {
let index = 0
const dbClientEntries = [...Object.entries(dbClients)]
const clientCount = dbClientEntries.length
while (true) {
// reset index
if (index === clientCount) index = 0
const [regionName, dbConnection] = dbClientEntries[index]
index++
yield [regionName, dbConnection.public]
}
}
main()
@@ -3,7 +3,7 @@
const http = require('http')
const prometheusClient = require('prom-client')
const knex = require('../knex')
const getDbClients = require('../knex')
let metricFree = null
let metricUsed = null
@@ -24,101 +24,105 @@ prometheusClient.collectDefaultMetrics()
let prometheusInitialized = false
function initKnexPrometheusMetrics() {
metricFree = new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(knex.client.pool.numFree())
}
})
const initDBPrometheusMetricsFactory =
({ db }) =>
() => {
metricFree = new prometheusClient.Gauge({
name: 'speckle_server_knex_free',
help: 'Number of free DB connections',
collect() {
this.set(db.client.pool.numFree())
}
})
metricUsed = new prometheusClient.Gauge({
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(knex.client.pool.numUsed())
}
})
metricUsed = new prometheusClient.Gauge({
name: 'speckle_server_knex_used',
help: 'Number of used DB connections',
collect() {
this.set(db.client.pool.numUsed())
}
})
metricPendingAquires = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(knex.client.pool.numPendingAcquires())
}
})
metricPendingAquires = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending',
help: 'Number of pending DB connection aquires',
collect() {
this.set(db.client.pool.numPendingAcquires())
}
})
metricPendingCreates = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(knex.client.pool.numPendingCreates())
}
})
metricPendingCreates = new prometheusClient.Gauge({
name: 'speckle_server_knex_pending_creates',
help: 'Number of pending DB connection creates',
collect() {
this.set(db.client.pool.numPendingCreates())
}
})
metricPendingValidations = new prometheusClient.Gauge({
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(knex.client.pool.numPendingValidations())
}
})
metricPendingValidations = new prometheusClient.Gauge({
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(db.client.pool.numPendingValidations())
}
})
metricRemainingCapacity = new prometheusClient.Gauge({
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
const postgresMaxConnections =
parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1
const demand =
knex.client.pool.numUsed() +
knex.client.pool.numPendingCreates() +
knex.client.pool.numPendingValidations() +
knex.client.pool.numPendingAcquires()
metricRemainingCapacity = new prometheusClient.Gauge({
name: 'speckle_server_knex_remaining_capacity',
help: 'Remaining capacity of the DB connection pool',
collect() {
const postgresMaxConnections =
parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1
const demand =
db.client.pool.numUsed() +
db.client.pool.numPendingCreates() +
db.client.pool.numPendingValidations() +
db.client.pool.numPendingAcquires()
this.set(Math.max(postgresMaxConnections - demand, 0))
}
})
this.set(Math.max(postgresMaxConnections - demand, 0))
}
})
metricQueryDuration = new prometheusClient.Summary({
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryDuration = new prometheusClient.Summary({
name: 'speckle_server_knex_query_duration',
help: 'Summary of the DB query durations in seconds'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
metricQueryErrors = new prometheusClient.Counter({
name: 'speckle_server_knex_query_errors',
help: 'Number of DB queries with errors'
})
knex.on('query', (data) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = Date.now()
})
db.on('query', (data) => {
const queryId = data.__knexQueryUid + ''
queryStartTime[queryId] = Date.now()
})
knex.on('query-response', (data, obj, builder) => {
const queryId = obj.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
})
db.on('query-response', (data, obj, builder) => {
const queryId = obj.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
})
knex.on('query-error', (err, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
db.on('query-error', (err, querySpec) => {
const queryId = querySpec.__knexQueryUid + ''
const durationSec = (Date.now() - queryStartTime[queryId]) / 1000
delete queryStartTime[queryId]
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
metricQueryErrors.inc()
})
}
if (!isNaN(durationSec)) metricQueryDuration.observe(durationSec)
metricQueryErrors.inc()
})
}
module.exports = {
initPrometheusMetrics() {
async initPrometheusMetrics() {
if (prometheusInitialized) return
prometheusInitialized = true
initKnexPrometheusMetrics()
const db = (await getDbClients()).main.public
initDBPrometheusMetricsFactory({ db })()
// Define the HTTP server
const server = http.createServer(async (req, res) => {
+16 -5
View File
@@ -7,12 +7,21 @@ from specklepy.api import operations
import sys, os
TMP_RESULTS_PATH = "/tmp/import_result.json"
DEFAULT_BRANCH = "uploads"
def import_stl():
file_path, _, stream_id, branch_name, commit_message = sys.argv[1:]
(
file_path,
tmp_results_path,
_,
stream_id,
branch_name,
commit_message,
_,
_,
_,
) = sys.argv[1:]
print(f"ImportSTL argv[1:]: {sys.argv[1:]}")
# Parse input
@@ -61,18 +70,20 @@ def import_stl():
source_application="STL",
)
return commit_id
return commit_id, tmp_results_path
if __name__ == "__main__":
from pathlib import Path
try:
commit_id = import_stl()
commit_id, tmp_results_path = import_stl()
if isinstance(commit_id, Exception):
raise commit_id
results = {"success": True, "commitId": commit_id}
except Exception as ex:
results = {"success": False, "error": str(ex)}
print(ex)
print(results)
Path(TMP_RESULTS_PATH).write_text(json.dumps(results))
Path(tmp_results_path).write_text(json.dumps(results))
+1 -1
View File
@@ -41,4 +41,4 @@ NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY=
NUXT_PUBLIC_ENABLE_DIRECT_PREVIEWS=true
# Ghost API
NUXT_PUBLIC_GHOST_API_KEY=
NUXT_PUBLIC_GHOST_API_KEY=
+13 -4
View File
@@ -34,14 +34,23 @@ RUN yarn workspaces foreach -W run build
RUN find ./packages/frontend-2/.output/ -type f \( -name "*.js.map" -o -name "*.mjs.map" -o -name "*.cjs.map" \) -exec rm -f {} \;
ENV TINI_VERSION=v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
RUN apt-get update -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
--no-install-recommends \
ca-certificates=20230311 \
curl=7.88.1-10+deb12u8 \
&& curl -fsSL https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini -o /tini \
&& chmod +x /tini \
&& apt-get remove -y curl \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:ed26b3ab750110c51d9dbdfd6c697561dc40a01c296460c3494d47b550ef4126 AS production-stage
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
COPY --from=build-stage /tini /tini
COPY --link --from=build-stage /tini /tini
ENTRYPOINT ["/tini", "--"]
@@ -53,7 +62,7 @@ ENV NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
ENV NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
WORKDIR /speckle-server
COPY --from=build-stage /speckle-server/packages/frontend-2/.output .
COPY --link --from=build-stage /speckle-server/packages/frontend-2/.output .
EXPOSE ${PORT}
+2
View File
@@ -12,6 +12,7 @@
<script setup lang="ts">
import { useTheme } from '~~/lib/core/composables/theme'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useFixBraveSafariCookies } from '~~/lib/common/composables/reactiveCookie'
const { isDarkTheme } = useTheme()
@@ -30,6 +31,7 @@ useHead({
const { watchAuthQueryString } = useAuthManager()
watchAuthQueryString()
useFixBraveSafariCookies()
</script>
<style>
.page-enter-active,
@@ -1,4 +0,0 @@
<svg width="38" height="37" viewBox="0 0 38 37" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.5695 7.84167C30.4459 7.87905 30.4465 8.05327 30.5704 8.08974L30.6008 8.09869C32.0949 8.53857 33.2612 9.70436 33.6956 11.1923C33.7314 11.3148 33.9059 11.3148 33.9425 11.1925C34.386 9.71452 35.5462 8.55207 37.0303 8.10336L37.0652 8.09282C37.1885 8.05553 37.1877 7.88161 37.064 7.84544L37.0283 7.835C35.5338 7.39799 34.3679 6.23128 33.938 4.74249C33.9028 4.62035 33.7287 4.62043 33.6922 4.7422C33.249 6.21893 32.0893 7.38202 30.6065 7.83048L30.5695 7.84167ZM31.4317 23.8166L31.4317 14.0863H31.4318V10.3168H31.4317V10.3162L26.998 10.3162V10.3168H17.2469L11.255 16.849V26.5794H11.2549V30.349H11.255V30.3494H15.6888V30.349L25.4397 30.349L31.4317 23.8166ZM26.998 26.5794L26.998 14.0863H15.6888L15.6888 26.5794H26.998Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 884 B

@@ -5,7 +5,7 @@
no-shadow
class="mx-auto w-full"
>
<div class="space-y-4">
<div class="flex flex-col gap-4">
<div v-if="!workspaceInvite" class="flex flex-col items-center gap-y-2 pb-4">
<h1 class="text-heading-xl text-center inline-block">
{{ title }}
@@ -22,23 +22,29 @@
:app-id="appId"
:newsletter-consent="false"
/>
<FormButton
v-if="isSsoEnabled"
color="outline"
full-width
size="lg"
:to="ssoLoginRoute"
>
Continue with SSO
</FormButton>
<div class="h-px w-full bg-outline-3 mt-2 shrink-0" />
<div>
<div
v-if="hasLocalStrategy"
class="text-center text-foreground-2 mb-2 text-body-2xs font-normal mt-2"
>
{{ hasThirdPartyStrategies ? 'Or login with your email' : '' }}
</div>
<AuthLoginWithEmailBlock
v-if="hasLocalStrategy"
:challenge="challenge"
:workspace-invite="workspaceInvite || undefined"
/>
<div v-if="!forcedInviteEmail" class="text-center text-body-sm">
<span class="mr-2">Don't have an account?</span>
<CommonTextLink :to="finalRegisterRoute" :icon-right="ArrowRightIcon">
Register
</CommonTextLink>
<div
v-if="!forcedInviteEmail"
class="text-center text-body-xs text-foreground-3 mt-2 select-none"
>
Don't have an account?
<NuxtLink class="text-foreground" :to="finalRegisterRoute">Sign up</NuxtLink>
</div>
</div>
</div>
@@ -50,8 +56,7 @@ import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils, useAuthManager } from '~~/lib/auth/composables/auth'
import { LayoutDialog } from '@speckle/ui-components'
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
import { registerRoute } from '~~/lib/common/helpers/route'
import { registerRoute, ssoLoginRoute } from '~~/lib/common/helpers/route'
import {
authLoginPanelQuery,
authLoginPanelWorkspaceInviteQuery
@@ -65,8 +70,7 @@ const props = withDefaults(
}>(),
{
dialogMode: false,
title: 'Speckle login',
subtitle: 'Connectivity, Collaboration and Automation for 3D'
title: 'Speckle login'
}
)
@@ -75,6 +79,7 @@ const { isLoggedIn } = useActiveUser()
const { inviteToken } = useAuthManager()
const router = useRouter()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const isSsoEnabled = useIsWorkspacesSsoEnabled()
const { result } = useQuery(authLoginPanelQuery)
@@ -25,11 +25,6 @@
:disabled="loading"
/>
</div>
<div class="mt-1">
<CommonTextLink :to="forgottenPasswordRoute">
Forgot your password?
</CommonTextLink>
</div>
<FormButton
size="lg"
submit
@@ -39,6 +34,12 @@
>
Log in
</FormButton>
<div class="mt-1 text-center text-body-xs text-foreground-3 select-none">
Forgot your password?
<NuxtLink :to="forgottenPasswordRoute" class="text-foreground">
Reset password
</NuxtLink>
</div>
</form>
</template>
<script setup lang="ts">
@@ -1,34 +1,28 @@
<template>
<form class="mx-auto w-full px-2" @submit="onSubmit">
<h1 class="text-heading-xl text-center inline-block mb-4">Reset your password</h1>
<h1 class="text-heading-xl text-center w-full inline-block mb-4">
Reset your password
</h1>
<div class="flex flex-col space-y-2 text-body-sm">
<div class="mb-4">One step closer to resetting your password</div>
<div class="flex flex-col space-y-4">
<p class="text-center text-body-xs text-foreground mb-2">
Choose a new password for your Speckle account
</p>
<FormTextInput
v-model="password"
type="password"
name="password"
label="Password"
placeholder="New password"
color="foundation"
size="lg"
:rules="passwordRules"
:rules="[isRequired]"
show-label
show-required
/>
<FormTextInput
type="password"
name="password-repeat"
label="Password (confirmation)"
color="foundation"
size="lg"
:rules="passwordRepeatRules"
placeholder="Confirm new password"
show-label
show-required
/>
<AuthPasswordChecks :password="password" />
</div>
<FormButton class="mt-4" submit full-width size="lg" :disabled="loading">
<FormButton class="mt-8" submit full-width size="lg" :disabled="loading">
Reset password
</FormButton>
</form>
@@ -36,11 +30,10 @@
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { usePasswordReset } from '~~/lib/auth/composables/passwordReset'
import { isRequired, isSameAs } from '~~/lib/common/helpers/validation'
import { isRequired } from '~~/lib/common/helpers/validation'
type FormValues = {
password: string
repeatPassword: string
}
const props = defineProps<{
@@ -50,9 +43,8 @@ const props = defineProps<{
const { handleSubmit } = useForm<FormValues>()
const { finalize } = usePasswordReset()
const passwordRules = [isRequired]
const passwordRepeatRules = [...passwordRules, isSameAs('password')]
const loading = ref(false)
const password = ref('')
const onSubmit = handleSubmit(
async ({ password }) => await finalize(password, props.token)
@@ -1,10 +1,11 @@
<template>
<form class="mx-auto w-full px-2" @submit="onSubmit">
<h1 class="text-heading-xl text-center inline-block mb-4">Reset your password</h1>
<div class="flex flex-col space-y-4 text-body-xs">
<div>
Type in the email address you used, so we can verify your account. We will send
you instructions on how to reset your password.
<h1 class="text-heading-xl text-center inline-block mb-4 w-full">
Reset your password
</h1>
<div class="flex flex-col space-y-4">
<div class="text-body-xs text-foreground text-center mb-2">
Enter your email address and we'll send you the password reset instructions.
</div>
<div>
<FormTextInput
@@ -18,12 +19,12 @@
</div>
</div>
<div class="flex flex-col gap-y-2 mt-4">
<div class="flex flex-col gap-y-2 mt-8">
<FormButton submit full-width size="lg" :disabled="loading">
Send password reset email
Send reset email
</FormButton>
<FormButton color="outline" size="lg" full-width :to="homeRoute">
Go home
Back to login
</FormButton>
</div>
</form>
@@ -0,0 +1,12 @@
<template>
<FormCheckbox
v-model="newsletterConsent"
name="newsletter"
label="Opt in for exclusive Speckle news and tips"
class="text-body-2xs"
/>
</template>
<script setup lang="ts">
const newsletterConsent = ref<true | undefined>(undefined)
</script>
@@ -5,22 +5,22 @@
<h1 class="text-heading-xl text-center inline-block">
Create your Speckle account
</h1>
<h2 class="text-body-sm text-center text-foreground-2">
Connectivity, Collaboration and Automation for 3D
</h2>
</div>
<AuthWorkspaceInviteHeader v-else :invite="workspaceInvite" />
<template v-if="isInviteOnly && !inviteToken">
<div class="flex space-x-2 items-center">
<ExclamationTriangleIcon class="h-8 w-8 text-warning" />
<div>
This server is invite only. If you have received an invitation email, please
follow the instructions in it.
</div>
</div>
<div v-if="!inviteEmail" class="flex space-x-2 items-center justify-center">
<CommonAlert color="warning">
<template #title>This server is invite only</template>
<template #description>
If you have received an invitation email, please follow the instructions in
it.
</template>
</CommonAlert>
<div
v-if="!inviteEmail"
class="flex gap-1 text-foregound-3 text-body-xs items-center justify-center"
>
<span>Already have an account?</span>
<CommonTextLink :to="loginRoute">Log in</CommonTextLink>
<NuxtLink class="text-foreground" :to="loginRoute">Log in</NuxtLink>
</div>
</template>
<template v-else>
@@ -31,6 +31,7 @@
:app-id="appId"
:newsletter-consent="newsletterConsent"
/>
<div>
<div
v-if="hasThirdPartyStrategies && hasLocalStrategy"
@@ -55,25 +56,8 @@ import { useQuery } from '@vue/apollo-composable'
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
import { useLoginOrRegisterUtils } from '~~/lib/auth/composables/auth'
import { graphql } from '~~/lib/common/generated/gql'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { loginRoute } from '~~/lib/common/helpers/route'
const registerPanelQuery = graphql(`
query AuthRegisterPanel($token: String) {
serverInfo {
inviteOnly
authStrategies {
id
}
...AuthStategiesServerInfoFragment
...ServerTermsOfServicePrivacyPolicyFragment
}
serverInviteByToken(token: $token) {
id
email
}
}
`)
import { authRegisterPanelQuery } from '~/lib/auth/graphql/queries'
const registerPanelWorkspaceInviteQuery = graphql(`
query AuthRegisterPanelWorkspaceInvite($token: String) {
@@ -86,7 +70,7 @@ const registerPanelWorkspaceInviteQuery = graphql(`
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
const { result } = useQuery(registerPanelQuery, () => ({
const { result } = useQuery(authRegisterPanelQuery, () => ({
token: inviteToken.value
}))
const { result: workspaceInviteResult } = useQuery(
@@ -0,0 +1,22 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
class="mt-2 text-body-2xs text-foreground-2 text-center terms-of-service"
v-html="serverInfo.termsOfService"
/>
</template>
<script setup lang="ts">
import type { ServerTermsOfServicePrivacyPolicyFragmentFragment } from '~/lib/common/generated/gql/graphql'
import { graphql } from '~~/lib/common/generated/gql'
defineProps<{
serverInfo: ServerTermsOfServicePrivacyPolicyFragmentFragment
}>()
graphql(`
fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {
termsOfService
}
`)
</script>
@@ -40,17 +40,8 @@
/>
</div>
<AuthPasswordChecks :password="password" class="mt-2 h-12 sm:h-8" />
<div class="mt-8 text-body-2xs flex px-2 text-foreground-2 space-x-2">
<!--
Note the newsletter consent box is here because i got very confused re layout of the panel
and didn't figure out a better way to put it where i needed it to be
-->
<FormCheckbox
v-model="newsletterConsent"
name="newsletter"
label="Opt in for exclusive Speckle news and tips"
class="text-body-xs"
/>
<div class="mt-8 flex px-2">
<AuthRegisterNewsletter v-model:newsletter-consent="newsletterConsent" />
</div>
<FormButton
submit
@@ -61,16 +52,10 @@
>
Sign up
</FormButton>
<div
v-if="serverInfo.termsOfService"
class="mt-2 text-body-2xs text-foreground-2 text-center terms-of-service"
v-html="serverInfo.termsOfService"
/>
<div v-if="!inviteEmail" class="mt-2 sm:mt-4 text-center text-body-sm">
<span class="mr-2">Already have an account?</span>
<CommonTextLink :to="finalLoginRoute" :icon-right="ArrowRightIcon">
Log in
</CommonTextLink>
<AuthRegisterTerms v-if="serverInfo.termsOfService" :server-info="serverInfo" />
<div v-if="!inviteEmail" class="mt-2 sm:mt-4 text-center text-body-xs">
<span class="mr-2 text-foreground-3">Already have an account?</span>
<NuxtLink class="text-foreground" :to="finalLoginRoute">Log in</NuxtLink>
</div>
</form>
</template>
@@ -84,7 +69,6 @@ import { loginRoute } from '~~/lib/common/helpers/route'
import { passwordRules } from '~~/lib/auth/helpers/validation'
import { graphql } from '~~/lib/common/generated/gql'
import type { ServerTermsOfServicePrivacyPolicyFragmentFragment } from '~~/lib/common/generated/gql/graphql'
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
import { useMounted } from '@vueuse/core'
/**
@@ -0,0 +1,185 @@
<template>
<form method="post" @submit="onSubmit">
<div class="flex flex-col gap-4">
<h1 class="text-heading-xl text-center mb-8">Speckle SSO login</h1>
<FormTextInput
v-model="email"
type="email"
name="email"
label="Your work email"
placeholder="Enter your email"
size="lg"
color="foundation"
:rules="[isEmail, isRequired]"
:loading="isChecking"
:help="helpText"
:custom-error-message="errorMessage"
show-label
:disabled="loading"
auto-focus
@update:model-value="onEmailChange"
/>
<AuthSsoWorkspaceSelect
v-if="shouldShowWorkspaceSelector"
:items="availableWorkspaces"
:disabled="loading"
/>
</div>
<div class="mt-8 space-y-4">
<FormButton size="lg" submit full-width :loading="loading" :disabled="!isValid">
{{ buttonText }}
</FormButton>
<FormButton size="lg" color="subtle" full-width :to="loginRoute">
Back to login
</FormButton>
</div>
</form>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { isEmail, isRequired } from '~/lib/common/helpers/validation'
import { loginRoute } from '~/lib/common/helpers/route'
import { useQuery } from '@vue/apollo-composable'
import { workspaceSsoByEmailQuery } from '~/lib/workspaces/graphql/queries'
import { useAuthManager, useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
import { useDebounceFn } from '@vueuse/core'
import type { AuthSsoLogin_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql/gql'
type FormValues = {
email: string
workspace?: AuthSsoLogin_WorkspaceFragment
}
enum EmailCheckState {
Idle = 'idle',
Checking = 'checking',
Checked = 'checked'
}
graphql(`
fragment AuthSsoLogin_Workspace on LimitedWorkspace {
id
slug
name
logo
}
`)
const { meta, handleSubmit, setFieldValue, values } = useForm<FormValues>({
initialValues: {
email: '',
workspace: undefined
}
})
const { challenge } = useLoginOrRegisterUtils()
const { signInOrSignUpWithSso } = useAuthManager()
const logger = useLogger()
const { triggerNotification } = useGlobalToast()
const loading = ref(false)
const email = ref('')
const emailCheckState = ref<EmailCheckState>(EmailCheckState.Idle)
const {
loading: isChecking,
result,
onResult
} = useQuery(
workspaceSsoByEmailQuery,
() => ({ email: email.value }),
() => ({
enabled: emailCheckState.value === 'checking'
})
)
const helpText = computed(() => {
if (isChecking.value) return 'Checking SSO availability...'
if (availableWorkspaces.value.length === 0 && emailCheckState.value === 'checked') {
return 'No SSO-enabled workspaces found for this email'
}
return undefined
})
const errorMessage = computed(() => {
if (emailCheckState.value === 'checked' && availableWorkspaces.value.length === 0) {
return 'This email is not associated with any SSO-enabled workspaces'
}
return undefined
})
const availableWorkspaces = computed(() => result.value?.workspaceSsoByEmail || [])
// Show workspace selector only if multiple options exist
const shouldShowWorkspaceSelector = computed(
() =>
availableWorkspaces.value.length > 1 &&
emailCheckState.value === EmailCheckState.Checked
)
// Valid when email passes validation and has associated workspaces
const isValid = computed(
() =>
meta.value.valid &&
emailCheckState.value === 'checked' &&
availableWorkspaces.value.length > 0
)
const buttonText = computed(() => {
if (isChecking.value) return 'Checking...'
if (!isValid.value) return 'Single Sign-On'
return values.workspace?.name ? `Sign in to ${values.workspace.name}` : 'Sign in'
})
const debouncedCheckEmail = useDebounceFn((value: string) => {
if (!value || !meta.value.valid) {
emailCheckState.value = EmailCheckState.Idle
return
}
emailCheckState.value = EmailCheckState.Checking
}, 300)
const onEmailChange = (value: string) => {
email.value = value
emailCheckState.value = EmailCheckState.Idle
setFieldValue('workspace', undefined)
debouncedCheckEmail(value)
}
const onSubmit = handleSubmit((values) => {
if (!values.workspace) return
loading.value = true
try {
signInOrSignUpWithSso({
challenge: challenge.value,
workspaceSlug: values.workspace.slug
})
} catch (error) {
logger.error('SSO login failed:', error)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'SSO login failed',
description:
error instanceof Error ? error.message : 'An unexpected error occurred'
})
} finally {
loading.value = false
}
})
onResult((res) => {
if (!res.data) return
emailCheckState.value = EmailCheckState.Checked
const workspaces = res.data.workspaceSsoByEmail || []
if (workspaces.length === 1) {
setFieldValue('workspace', workspaces[0])
}
})
</script>
@@ -0,0 +1,83 @@
<template>
<div class="flex flex-col items-center gap-4">
<WorkspaceAvatar
v-if="workspace"
:name="workspace.name"
:logo="workspace.logo"
size="xl"
/>
<h1 class="text-heading-xl text-center">
Sign up to access {{ workspace?.name || 'your Workspace' }}
</h1>
<div class="w-full max-w-xs">
<AuthRegisterNewsletter v-model:newsletter-consent="newsletterConsent" />
<div class="my-4">
<FormButton
size="lg"
full-width
:loading="loading"
:disabled="loading"
@click="handleContinue"
>
Continue with {{ workspace?.ssoProviderName || 'SSO' }}
</FormButton>
</div>
<AuthRegisterTerms v-if="serverInfo" :server-info="serverInfo" />
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthManager, useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
import { useWorkspacePublicSsoCheck } from '~/lib/workspaces/composables/sso'
import { useMixpanel } from '~/lib/core/composables/mp'
import { authRegisterPanelQuery } from '~/lib/auth/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
const route = useRoute()
const loading = ref(false)
const newsletterConsent = ref<true | undefined>(undefined)
const { challenge } = useLoginOrRegisterUtils()
const { signInOrSignUpWithSso } = useAuthManager()
const mixpanel = useMixpanel()
const logger = useLogger()
const { result } = useQuery(authRegisterPanelQuery)
const serverInfo = computed(() => result.value?.serverInfo)
const workspaceSlug = computed(() => route.params.slug?.toString() || '')
const { workspace } = useWorkspacePublicSsoCheck(workspaceSlug)
const handleContinue = () => {
if (!workspaceSlug.value) return
loading.value = true
try {
mixpanel.track('Workspace SSO Register Attempted', {
// eslint-disable-next-line camelcase
workspace_slug: workspaceSlug.value
})
signInOrSignUpWithSso({
challenge: challenge.value,
workspaceSlug: workspaceSlug.value,
newsletterConsent: !!newsletterConsent.value
})
} catch (error) {
logger.error('SSO registration failed:', error)
mixpanel.track('Workspace SSO Registration Failed', {
// eslint-disable-next-line camelcase
workspace_slug: workspaceSlug.value
})
} finally {
loading.value = false
mixpanel.track('Workspace SSO Registration Successful', {
// eslint-disable-next-line camelcase
workspace_slug: workspaceSlug.value
})
}
}
</script>
@@ -0,0 +1,50 @@
<template>
<FormSelectBase
v-bind="props"
v-model="selectedValue"
label="Select workspace"
name="workspace"
:rules="workspaceRules"
help="You may need to authenticate separately for each workspace you want to access."
>
<template #option="{ item }">
<div class="flex items-center gap-2">
<WorkspaceAvatar :name="item.name" :logo="item.logo" size="xs" />
<span>{{ item.name }}</span>
</div>
</template>
<template #nothing-selected>Select a workspace</template>
<template #something-selected="{ value }">
<div v-if="!isArrayValue(value)" class="flex items-center gap-2">
<WorkspaceAvatar :logo="value.logo" :name="value.name" size="xs" />
<span>{{ value.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
import type { AuthSsoLogin_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
import { isRequired } from '~/lib/common/helpers/validation'
const props = defineProps<{
modelValue?: AuthSsoLogin_WorkspaceFragment
items: AuthSsoLogin_WorkspaceFragment[]
}>()
const emit = defineEmits<{
(
e: 'update:modelValue',
v: AuthSsoLogin_WorkspaceFragment | AuthSsoLogin_WorkspaceFragment[] | undefined
): void
}>()
const workspaceRules = computed(() => [isRequired])
const { selectedValue, isArrayValue } =
useFormSelectChildInternals<AuthSsoLogin_WorkspaceFragment>({
props: toRefs(props),
emit
})
</script>
@@ -1,15 +1,13 @@
<template>
<div>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
<Component
:is="getButtonComponent(strat)"
v-for="strat in thirdPartyStrategies"
:key="strat.id"
to="javascript:;"
:server-info="serverInfo"
@click="() => onClick(strat)"
/>
</div>
<div class="flex flex-col gap-3">
<Component
:is="getButtonComponent(strat)"
v-for="strat in thirdPartyStrategies"
:key="strat.id"
to="javascript:;"
:server-info="serverInfo"
@click="() => onClick(strat)"
/>
</div>
</template>
<script setup lang="ts">
@@ -1,21 +1,21 @@
<template>
<NuxtLink
<FormButton
color="outline"
size="lg"
full-width
class="group"
:to="to"
:class="[
'transition grow px-3 inline-flex justify-center items-center outline-none h6 font-medium leading-7',
'rounded shadow bg-foundation- text-foreground dark:text-foreground-on-primary',
'border border-outline-2 hover:border-outline-3',
noVerticalPadding ? '' : 'py-2'
]"
:external="true"
:link-component="'a'"
>
<div class="flex items-center justify-center">
<slot />
</div>
</NuxtLink>
</FormButton>
</template>
<script setup lang="ts">
defineProps<{
to: string
noVerticalPadding?: boolean
}>()
</script>
@@ -1,11 +1,11 @@
<template>
<AuthThirdPartyLoginButtonBase :to="to" class="dark:border dark:border-[#475569]">
<AuthThirdPartyLoginButtonBase :to="to">
<img
src="~/assets/images/auth/github_icon.svg"
alt="GitHub Sign In"
class="w-4 dark:invert"
class="w-4 dark:invert grayscale group-hover:grayscale-0"
/>
<div class="ml-3">Github</div>
<div class="ml-2">Continue with Github</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
@@ -3,9 +3,9 @@
<img
src="~/assets/images/auth/google_icon_w_bg.svg"
alt="Google Sign In"
class="w-11"
class="w-9 grayscale grayscale group-hover:grayscale-0"
/>
<div>Google</div>
<div>Continue with Google</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
@@ -1,7 +1,11 @@
<template>
<AuthThirdPartyLoginButtonBase :to="to" class="dark:bg-[#2F2F2F]">
<img src="~/assets/images/auth/ms_icon.svg" alt="Microsoft Sign In" class="w-4" />
<div>Microsoft</div>
<AuthThirdPartyLoginButtonBase :to="to">
<img
src="~/assets/images/auth/ms_icon.svg"
alt="Microsoft Sign In"
class="w-4 grayscale mr-2 group-hover:grayscale-0"
/>
<div>Continue with Microsoft</div>
</AuthThirdPartyLoginButtonBase>
</template>
<script setup lang="ts">
@@ -9,45 +9,41 @@
prevent-close-on-click-outside
@fully-closed="reset"
>
<template v-if="isTestAutomation" #header>
Create
<span class="font-extrabold text-fancy-gradient">test</span>
automation
</template>
<div class="flex flex-col gap-6">
<template v-if="isTestAutomation" #header>Create test automation</template>
<div class="flex flex-col gap-4">
<CommonStepsNumber
v-if="shouldShowStepsWidget"
v-model="stepsWidgetModel"
class="mb-2"
class="my-2"
:steps="stepsWidgetSteps"
:go-vertical-below="TailwindBreakpoints.sm"
non-interactive
/>
<CommonAlert v-if="isTestAutomation" color="info">
<template #title>What is a "test automation"?</template>
<template #description>
<ul class="list-disc ml-4">
<li>
A test automation is a sandbox environment that allows you to connect your
local development environment for testing purposes. It enables you to run
your code against project data and submit results directly to the
connected test automation.
</li>
<li>
Unlike regular automations, test automations are not triggered by changes
to project data. They cannot be started by pushing a new version to a
model.
</li>
<li>Consequently, test automations do not execute published functions.</li>
</ul>
</template>
</CommonAlert>
<CommonCard v-if="isTestAutomation" class="bg-foundation py-3 px-4">
<p class="text-body-xs font-medium text-foreground">
What is a "test automation"?
</p>
<ul class="list-disc ml-4 text-body-xs text-foreground-2 mt-2">
<li>
A test automation is a sandbox environment that allows you to connect your
local development environment for testing purposes. It enables you to run
your code against project data and submit results directly to the connected
test automation.
</li>
<li>
Unlike regular automations, test automations are not triggered by changes to
project data. They cannot be started by pushing a new version to a model.
</li>
<li>Consequently, test automations do not execute published functions.</li>
</ul>
</CommonCard>
<AutomateAutomationCreateDialogSelectFunctionStep
v-if="enumStep === AutomationCreateSteps.SelectFunction"
v-model:selected-function="selectedFunction"
:show-label="false"
:show-required="false"
:preselected-function="validatedPreselectedFunction"
:workspace-id="workspaceId"
/>
<AutomateAutomationCreateDialogFunctionParametersStep
v-else-if="
@@ -65,12 +61,14 @@
v-model:automation-name="automationName"
:preselected-project="preselectedProject"
:is-test-automation="isTestAutomation"
:workspace-id="workspaceId"
/>
<AutomateAutomationCreateDialogSelectFunctionStep
v-if="isTestAutomation"
v-model:selected-function="selectedFunction"
:preselected-function="validatedPreselectedFunction"
:page-size="2"
:workspace-id="workspaceId"
/>
</template>
</div>
@@ -83,11 +81,6 @@ import {
TailwindBreakpoints,
type LayoutDialogButton
} from '@speckle/ui-components'
import {
ChevronLeftIcon,
ChevronRightIcon,
CodeBracketIcon
} from '@heroicons/vue/24/outline'
import { graphql } from '~/lib/common/generated/gql'
import { Automate, type Optional } from '@speckle/shared'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
@@ -134,6 +127,7 @@ graphql(`
`)
const props = defineProps<{
workspaceId?: string
preselectedFunction?: Optional<CreateAutomationSelectableFunction>
preselectedProject?: Optional<FormSelectProjects_ProjectFragment>
}>()
@@ -215,8 +209,7 @@ const buttons = computed((): LayoutDialogButton[] => {
id: 'createTestAutomation',
text: 'Create test automation',
props: {
color: 'outline',
iconLeft: CodeBracketIcon
color: 'outline'
},
onClick: () => {
isTestAutomation.value = true
@@ -227,10 +220,10 @@ const buttons = computed((): LayoutDialogButton[] => {
id: 'selectFnNext',
text: 'Next',
props: {
iconRight: ChevronRightIcon,
disabled: !selectedFunction.value
},
onClick: () => {
mixpanel.track('Automate Select Function')
step.value++
}
}
@@ -241,17 +234,17 @@ const buttons = computed((): LayoutDialogButton[] => {
id: 'fnParamsPrev',
text: 'Previous',
props: {
color: 'outline',
iconLeft: ChevronLeftIcon,
class: '!text-primary'
color: 'outline'
},
onClick: () => step.value--
},
{
id: 'fnParamsNext',
text: 'Next',
onClick: () => {
mixpanel.track('Automate Set Function Parameters ')
},
props: {
iconRight: ChevronRightIcon,
disabled: hasParameterErrors.value
},
submit: true
@@ -263,14 +256,16 @@ const buttons = computed((): LayoutDialogButton[] => {
id: 'detailsPrev',
text: 'Previous',
props: {
color: 'outline',
iconLeft: ChevronLeftIcon
color: 'outline'
},
onClick: () => step.value--
},
{
id: 'detailsCreate',
text: 'Create',
onClick: () => {
mixpanel.track('Automate Set Automation Details')
},
submit: true,
disabled: creationLoading.value
}
@@ -281,8 +276,7 @@ const buttons = computed((): LayoutDialogButton[] => {
id: 'detailsPrev',
text: 'Back',
props: {
color: 'outline',
iconLeft: ChevronLeftIcon
color: 'outline'
},
onClick: reset
},
@@ -445,7 +439,7 @@ const onDetailsSubmit = handleDetailsSubmit(async () => {
return
}
mixpanel.track('Automation created', {
mixpanel.track('Automate Automation Created', {
automationId: aId,
name,
projectId: project.id,
@@ -12,6 +12,7 @@
:allow-unset="false"
validate-on-value-update
owned-only
:workspace-id="workspaceId"
/>
<FormSelectModels
v-if="project?.id"
@@ -51,6 +52,7 @@ import type {
const props = defineProps<{
preselectedProject?: Optional<FormSelectProjects_ProjectFragment>
isTestAutomation: boolean
workspaceId?: string
}>()
const project = defineModel<Optional<FormSelectProjects_ProjectFragment>>('project', {
required: true
@@ -6,7 +6,7 @@
:show-required="showRequired"
name="search"
color="foundation"
placeholder="Search Functions..."
placeholder="Search functions..."
show-clear
full-width
v-bind="bind"
@@ -43,13 +43,19 @@ import type { Optional } from '@speckle/shared'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
const searchQuery = graphql(`
query AutomationCreateDialogFunctionsSearch($search: String, $cursor: String = null) {
automateFunctions(limit: 20, filter: { search: $search }, cursor: $cursor) {
cursor
totalCount
items {
id
...AutomateAutomationCreateDialog_AutomateFunction
query AutomationCreateDialogFunctionsSearch(
$workspaceId: String!
$search: String
$cursor: String = null
) {
workspace(id: $workspaceId) {
automateFunctions(limit: 20, filter: { search: $search }, cursor: $cursor) {
cursor
totalCount
items {
id
...AutomateAutomationCreateDialog_AutomateFunction
}
}
}
}
@@ -57,6 +63,7 @@ const searchQuery = graphql(`
const props = withDefaults(
defineProps<{
workspaceId?: string
preselectedFunction: Optional<CreateAutomationSelectableFunction>
pageSize?: Optional<number>
showLabel?: Optional<boolean>
@@ -83,10 +90,11 @@ const {
} = usePaginatedQuery({
query: searchQuery,
baseVariables: computed(() => ({
search: search.value?.length ? search.value : null
workspaceId: props.workspaceId ?? '',
search: search.value?.length ? search.value : ''
})),
resolveKey: (vars) => [vars.search || ''],
resolveCurrentResult: (res) => res?.automateFunctions,
resolveCurrentResult: (res) => res?.workspace?.automateFunctions,
resolveNextPageVariables: (baseVars, cursor) => ({
...baseVars,
cursor
@@ -94,7 +102,9 @@ const {
resolveCursorFromVariables: (vars) => vars.cursor
})
const queryItems = computed(() => result.value?.automateFunctions.items)
const queryItems = computed(() => {
return result.value?.workspace?.automateFunctions.items
})
const items = computed(() => {
const baseItems = (queryItems.value || []).slice(0, props.pageSize)
const preselectedFn = props.preselectedFunction
@@ -2,80 +2,71 @@
<Component
:is="noButtons ? NuxtLink : 'div'"
:class="classes"
:to="noButtons ? automationFunctionRoute(fn.id) : undefined"
:to="noButtons ? automateFunctionRoute(fn.id) : undefined"
:external="externalMoreInfo"
:target="externalMoreInfo ? '_blank' : undefined"
class="rounded-lg border border-outline-3 bg-foundation overflow-hidden"
>
<div
class="px-4 py-4 flex flex-col gap-3 rounded-lg border border-outline-3 bg-foundation relative h-full"
>
<div class="px-4 py-4 flex flex-col gap-3 relative h-full">
<div class="flex gap-3 items-center" :class="{ 'w-4/5': hasLabel }">
<AutomateFunctionLogo :logo="fn.logo" />
<div class="flex flex-col truncate">
<div
:class="[
'text-heading text-foreground truncate',
'text-heading-sm text-foreground truncate',
noButtons ? '' : 'hover:underline'
]"
>
<Component
:is="noButtons ? 'div' : NuxtLink"
:to="automationFunctionRoute(fn.id)"
:to="automateFunctionRoute(fn.id)"
:target="externalMoreInfo ? '_blank' : undefined"
class="truncate"
>
{{ fn.name }}
</Component>
</div>
<div class="text-body-xs flex items-center space-x-1 -mt-1">
<div class="text-body-2xs flex items-center text-foreground-2 space-x-0.5">
<span>by</span>
<Component
:is="noButtons ? 'div' : CommonTextLink"
external
:to="fn.repo.url"
>
<Component :is="noButtons ? 'div' : NuxtLink" :to="fn.repo.url" external>
{{ fn.repo.owner }}
</Component>
</div>
</div>
</div>
<div class="text-body-xs text-foreground-2 line-clamp-3 h-18 whitespace-normal">
<div class="text-body-xs text-foreground-2 line-clamp-3 h-18">
{{ plaintextDescription }}
</div>
<div v-if="!noButtons" class="flex flex-col sm:flex-row sm:self-end gap-2">
<div v-if="!noButtons" class="flex flex-col sm:flex-row gap-x-1">
<template v-if="showEdit">
<FormButton
:icon-left="PencilIcon"
full-width
color="outline"
@click="$emit('edit')"
>
Edit Details
<FormButton full-width color="outline" @click="$emit('edit')">
Edit details
</FormButton>
</template>
<template v-else>
<FormButton
text
:to="automationFunctionRoute(fn.id)"
:external="externalMoreInfo"
:target="externalMoreInfo ? '_blank' : undefined"
>
Learn More
</FormButton>
<FormButton
:icon-left="selected ? CheckIcon : undefined"
@click="$emit('use')"
>
{{ selected ? 'Selected' : 'Select' }}
</FormButton>
<FormButton
color="subtle"
:to="automateFunctionRoute(fn.id)"
:external="externalMoreInfo"
:target="externalMoreInfo ? '_blank' : undefined"
>
Learn more
</FormButton>
</template>
</div>
<div class="absolute top-0 right-0">
<div
v-if="hasLabel"
class="rounded-bl-lg rounded-tr-[7px] text-body-2xs px-2 py-1"
class="rounded-bl-md rounded-tr-lg font-medium text-body-3xs px-2 py-1"
:class="{
'bg-foundation-focus text-foreground': fn.isFeatured,
'bg-warning text-foreground-on-primary': isOutdated
'bg-info-lighter text-outline-4': fn.isFeatured,
'bg-danger-lighter text-danger-darker': isOutdated
}"
>
<template v-if="isOutdated">Outdated</template>
@@ -88,10 +79,9 @@
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql'
import type { AutomationsFunctionsCard_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
import { CheckIcon, PencilIcon } from '@heroicons/vue/24/outline'
import { automationFunctionRoute } from '~/lib/common/helpers/route'
import { CheckIcon } from '@heroicons/vue/24/outline'
import { automateFunctionRoute } from '~/lib/common/helpers/route'
import { useMarkdown } from '~/lib/common/composables/markdown'
import { CommonTextLink } from '@speckle/ui-components'
graphql(`
fragment AutomationsFunctionsCard_AutomateFunction on AutomateFunction {
@@ -134,9 +124,9 @@ const classes = computed(() => {
const classParts = ['rounded-lg']
if (props.selected) {
classParts.push('ring-2 ring-primary')
classParts.push('border-primary')
} else if (props.noButtons) {
classParts.push('ring-outline-2 hover:ring-2 cursor-pointer')
classParts.push('hover:border-outline-5 cursor-pointer')
}
return classParts.join(' ')
@@ -52,14 +52,21 @@ import type {
} from '~/lib/automate/helpers/functions'
import {
automateGithubAppAuthorizationRoute,
automationFunctionRoute
automateFunctionRoute
} from '~/lib/common/helpers/route'
import { useEnumSteps, useEnumStepsWidgetSetup } from '~/lib/form/composables/steps'
import { useForm } from 'vee-validate'
import { useCreateAutomateFunction } from '~/lib/automate/composables/management'
import {
useCreateAutomateFunction,
useUpdateAutomateFunction
} from '~/lib/automate/composables/management'
import { useMutationLoading } from '@vue/apollo-composable'
import type { AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment } from '~~/lib/common/generated/gql/graphql'
import type {
AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment,
AutomateFunctionCreateDialog_WorkspaceFragment
} from '~~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
import { graphql } from '~/lib/common/generated/gql'
enum FunctionCreateSteps {
Authorize,
@@ -70,10 +77,19 @@ enum FunctionCreateSteps {
type DetailsFormValues = FunctionDetailsFormValues
graphql(`
fragment AutomateFunctionCreateDialog_Workspace on Workspace {
id
name
slug
}
`)
const props = defineProps<{
isAuthorized: boolean
templates: CreatableFunctionTemplate[]
githubOrgs: string[]
workspace?: AutomateFunctionCreateDialog_WorkspaceFragment
}>()
const open = defineModel<boolean>('open', { required: true })
@@ -81,6 +97,7 @@ const mixpanel = useMixpanel()
const logger = useLogger()
const mutationLoading = useMutationLoading()
const createFunction = useCreateAutomateFunction()
const updateFunction = useUpdateAutomateFunction()
const { handleSubmit: handleDetailsSubmit } = useForm<DetailsFormValues>()
const onDetailsSubmit = handleDetailsSubmit(async (values) => {
if (!selectedTemplate.value) {
@@ -99,20 +116,37 @@ const onDetailsSubmit = handleDetailsSubmit(async (values) => {
}
})
if (res?.id) {
mixpanel.track('Automate Function Created', {
functionId: res.id,
templateId: selectedTemplate.value.id,
name: values.name
})
createdFunction.value = res
step.value++
if (!res?.id) {
// TODO: Error toast with butter
return
}
mixpanel.track('Automate Function Created', {
functionId: res.id,
templateId: selectedTemplate.value.id,
name: values.name,
/* eslint-disable-next-line camelcase */
workspace_id: props.workspace?.id
})
createdFunction.value = res
step.value++
if (!props.workspace?.id) {
return
}
await updateFunction({
input: {
id: res.id,
workspaceIds: [props.workspace.id]
}
})
})
const onSubmit = computed(() => {
switch (enumStep.value) {
case FunctionCreateSteps.Details:
mixpanel.track('Automate Configure Function Details')
return onDetailsSubmit
default:
return noop
@@ -162,7 +196,10 @@ const title = computed(() => {
})
const authorizeGithubUrl = computed(() => {
const redirectUrl = new URL(automateGithubAppAuthorizationRoute, apiBaseUrl)
const redirectUrl = new URL(
automateGithubAppAuthorizationRoute(props.workspace?.slug),
apiBaseUrl
)
return redirectUrl.toString()
})
@@ -182,6 +219,9 @@ const buttons = computed((): LayoutDialogButton[] => {
{
id: 'authorizeAuthorize',
text: 'Authorize',
onClick: () => {
mixpanel.track('Automate Start Authorize GitHub App')
},
props: {
fullWidth: true,
to: authorizeGithubUrl.value,
@@ -198,7 +238,10 @@ const buttons = computed((): LayoutDialogButton[] => {
iconRight: ChevronRightIcon,
disabled: !selectedTemplate.value
},
onClick: () => step.value++
onClick: () => {
mixpanel.track('Automate Select Function Template')
step.value++
}
}
]
case FunctionCreateSteps.Details:
@@ -238,7 +281,7 @@ const buttons = computed((): LayoutDialogButton[] => {
iconRight: ArrowRightIcon,
fullWidth: true,
to: createdFunction.value?.id
? automationFunctionRoute(createdFunction.value.id)
? automateFunctionRoute(createdFunction.value.id)
: undefined
}
}
@@ -18,10 +18,20 @@ import { difference, differenceBy } from 'lodash-es'
import { useForm } from 'vee-validate'
import { useUpdateAutomateFunction } from '~/lib/automate/composables/management'
import type { FunctionDetailsFormValues } from '~/lib/automate/helpers/functions'
import { graphql } from '~/lib/common/generated/gql'
import type { AutomateFunctionEditDialog_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment AutomateFunctionEditDialog_Workspace on Workspace {
id
name
}
`)
const props = defineProps<{
model: FunctionDetailsFormValues
fnId: string
workspaces?: AutomateFunctionEditDialog_WorkspaceFragment[]
}>()
const open = defineModel<boolean>('open', { required: true })
const { handleSubmit, setValues } = useForm<FunctionDetailsFormValues>()
@@ -53,6 +63,7 @@ const onSubmit = handleSubmit(async (values) => {
values.description !== props.model.description ? values.description : null,
logo: values.image !== props.model.image ? values.image : null,
tags: difference(values.tags, props.model.tags || []).length ? values.tags : null,
workspaceIds: values.workspace ? [values.workspace.id] : [],
supportedSourceApps: differenceBy(
values.allowedSourceApps,
props.model.allowedSourceApps || [],
@@ -33,6 +33,30 @@
:rules="descriptionRules"
validate-on-value-update
/>
<FormSelectBase
v-if="workspaces?.length"
name="workspace"
label="Workspace"
placeholder="Select a workspace"
show-label
allow-unset
clearable
help="Allow automations in one of your workspaces to use this function."
:items="workspaces"
>
<template #something-selected="{ value }">
<div class="label label--light">
{{ isArray(value) ? value[0].name : value.name }}
</div>
</template>
<template #option="{ item, selected }">
<div class="flex flex-col">
<div :class="['label label--light', selected ? 'text-primary' : '']">
{{ item.name }}
</div>
</div>
</template>
</FormSelectBase>
<FormSelectSourceApps
name="allowedSourceApps"
label="Supported source apps"
@@ -85,9 +109,11 @@
<script setup lang="ts">
import { ValidationHelpers } from '@speckle/ui-components'
import { isArray } from 'lodash-es'
import type { AutomateFunctionCreateDialog_WorkspaceFragment } from '~/lib/common/generated/gql/graphql'
defineProps<{
githubOrgs?: string[]
workspaces?: AutomateFunctionCreateDialog_WorkspaceFragment[]
}>()
const avatarEditMode = ref(false)
@@ -1,54 +1,51 @@
<template>
<div class="pt-4 flex gap-4 flex-col sm:flex-row sm:items-center sm:justify-between">
<Portal to="navigation">
<HeaderNavLink
:to="automationFunctionsRoute"
:name="'Automate functions'"
></HeaderNavLink>
<HeaderNavLink
:to="automationFunctionRoute(fn.id)"
:name="fn.name"
></HeaderNavLink>
<template v-if="fnWorkspace">
<HeaderNavLink
:to="workspaceRoute(fnWorkspace.slug)"
:separator="false"
:name="fnWorkspace.name"
/>
<HeaderNavLink
:to="workspaceFunctionsRoute(fnWorkspace.slug)"
name="Functions"
/>
<HeaderNavLink :to="automateFunctionRoute(fn.id)" :name="fn.name" />
</template>
<template v-else>
<HeaderNavLink
:to="publicAutomateFunctionsRoute"
:separator="false"
name="Functions"
/>
<HeaderNavLink :to="automateFunctionRoute(fn.id)" :name="fn.name" />
</template>
</Portal>
<div class="flex items-center gap-4">
<AutomateFunctionLogo :logo="fn.logo" />
<h1 class="text-heading-lg">{{ fn.name }}</h1>
<FormButton v-if="isOwner" size="sm" text class="mt-1" @click="$emit('edit')">
Edit
</FormButton>
</div>
<div
v-tippy="
hasReleases ? undefined : 'Your function needs to have at least one release'
"
class="flex gap-2 shrink-0"
>
<FormButton
:icon-left="BoltIcon"
class="shrink-0"
full-width
:disabled="!hasReleases"
@click="$emit('createAutomation')"
>
Use in an automation
<div class="flex items-center align-center gap-2">
<FormButton v-if="isOwner" color="outline" @click="$emit('edit')">
Edit
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BoltIcon } from '@heroicons/vue/24/outline'
import { graphql } from '~/lib/common/generated/gql'
import type { AutomateFunctionPageHeader_FunctionFragment } from '~/lib/common/generated/gql/graphql'
import type {
AutomateFunctionPageHeader_FunctionFragment,
AutomateFunctionPageHeader_WorkspaceFragment
} from '~/lib/common/generated/gql/graphql'
import {
automationFunctionRoute,
automationFunctionsRoute
automateFunctionRoute,
publicAutomateFunctionsRoute,
workspaceFunctionsRoute,
workspaceRoute
} from '~/lib/common/helpers/route'
defineEmits<{
createAutomation: []
edit: []
}>()
graphql(`
fragment AutomateFunctionPageHeader_Function on AutomateFunction {
id
@@ -63,13 +60,24 @@ graphql(`
releases(limit: 1) {
totalCount
}
workspaceIds
}
fragment AutomateFunctionPageHeader_Workspace on Workspace {
id
name
slug
}
`)
const props = defineProps<{
defineProps<{
fn: AutomateFunctionPageHeader_FunctionFragment
fnWorkspace?: AutomateFunctionPageHeader_WorkspaceFragment
isOwner: boolean
}>()
const hasReleases = computed(() => props.fn.releases.totalCount > 0)
defineEmits<{
createAutomation: []
edit: []
}>()
</script>
@@ -1,14 +1,13 @@
<template>
<div class="flex flex-col gap-6">
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2">
<AutomateFunctionPageInfoBlock :icon="CodeBracketIcon" title="Source">
<AutomateFunctionPageInfoBlock title="Source">
<div class="space-y-1">
<CommonTextLink
v-tippy="license"
external
:to="repoUrl"
target="_blank"
:icon-right="ArrowTopRightOnSquareIcon"
class="max-w-full"
>
<span class="truncate">{{ repo }}</span>
@@ -19,7 +18,6 @@
external
:to="githubDetails.owner.html_url"
target="_blank"
:icon-right="ArrowTopRightOnSquareIcon"
class="max-w-full"
>
<span class="truncate">
@@ -34,36 +32,22 @@
</div>
</div>
</AutomateFunctionPageInfoBlock>
<AutomateFunctionPageInfoBlock :icon="InformationCircleIcon" title="Info">
<div class="gap-y-2 text-body-sm">
<AutomateFunctionPageInfoBlock title="Info">
<div class="gap-y-2 text-body-xs">
<div v-if="latestRelease">
<span>Last published:&nbsp;</span>
<CommonText class="font-medium" :text="publishedAt" />
<span class="font-medium">Last published:&nbsp;</span>
<CommonText :text="publishedAt" />
</div>
<div>
<span>Used by:&nbsp;</span>
<CommonText
class="font-medium"
:text="`${fn.automationCount} automations`"
/>
</div>
<CommonTextLink
v-if="latestRelease?.inputSchema"
:icon-right="ArrowTopRightOnSquareIcon"
@click="onViewParameters"
>
<CommonTextLink v-if="latestRelease?.inputSchema" @click="onViewParameters">
View parameters
</CommonTextLink>
</div>
</AutomateFunctionPageInfoBlock>
</div>
<AutomateFunctionPageInfoBlock
title="Description"
:icon="ChatBubbleBottomCenterTextIcon"
>
<AutomateFunctionPageInfoBlock title="Description">
<CommonProseMarkdownDescription :markdown="description" />
</AutomateFunctionPageInfoBlock>
<AutomateFunctionPageInfoBlock title="Readme" :icon="BookOpenIcon">
<AutomateFunctionPageInfoBlock title="Readme">
<CommonProseGithubReadme
:readme-markdown="rawReadme || ''"
:repo="repo || ''"
@@ -86,12 +70,11 @@
class="shrink-0"
>
<FormButton
:icon-left="BoltIcon"
class="shrink-0"
:disabled="!hasReleases"
@click="$emit('createAutomation')"
>
Use in an automation
Use in automation
</FormButton>
</div>
</div>
@@ -103,14 +86,6 @@
</div>
</template>
<script setup lang="ts">
import {
CodeBracketIcon,
InformationCircleIcon,
ArrowTopRightOnSquareIcon,
BookOpenIcon,
BoltIcon,
ChatBubbleBottomCenterTextIcon
} from '@heroicons/vue/24/outline'
import dayjs from 'dayjs'
import {
useGetGithubRepo,
@@ -131,7 +106,6 @@ graphql(`
owner
name
}
automationCount
description
releases(limit: 1) {
items {
@@ -2,9 +2,8 @@
<div
class="bg-foundation basis-1/2 shrink-0 grow-0 border border-outline-3 rounded-lg"
>
<div class="flex items-center gap-2 px-4 py-2 border-b border-outline-3 mb-4">
<Component :is="icon" class="w-5 h-5" />
<h3 class="text-heading">{{ title }}</h3>
<div class="px-4 py-2 border-b border-outline-3 mb-4">
<h3 class="text-heading-sm">{{ title }}</h3>
</div>
<div class="px-4 pb-4">
<slot />
@@ -12,10 +11,7 @@
</div>
</template>
<script setup lang="ts">
import type { PropAnyComponent } from '@speckle/ui-components'
defineProps<{
icon: PropAnyComponent
title: string
}>()
</script>
@@ -1,19 +1,19 @@
<template>
<LayoutDialog v-model:open="open" max-width="md" title="Function Parameters">
<LayoutDialog
v-model:open="open"
:buttons="dialogButtons"
max-width="md"
title="Function parameters"
>
<div class="flex flex-col space-y-4">
<template v-if="finalParams">
<FormJsonForm :schema="finalParams" />
<LayoutDialogSection
title="Parameter schema"
:icon="BeakerIcon"
border-t
border-b
>
<LayoutDialogSection title="Parameter schema" border-t border-b>
<FormTextArea
name="actionYaml"
readonly
:model-value="JSON.stringify(finalParams, null, 2)"
class="text-sm text-primary"
textarea-classes="!bg-foundation !border border-outline-2 p-2 rounded-lg font-mono !font-body-2xs"
rows="15"
/>
</LayoutDialogSection>
@@ -27,11 +27,11 @@
</LayoutDialog>
</template>
<script setup lang="ts">
import { BeakerIcon } from '@heroicons/vue/24/outline'
import { LayoutDialogSection } from '@speckle/ui-components'
import { formatVersionParams } from '~/lib/automate/helpers/jsonSchema'
import { graphql } from '~/lib/common/generated/gql'
import type { AutomateFunctionPageParametersDialog_AutomateFunctionReleaseFragment } from '~/lib/common/generated/gql/graphql'
import type { LayoutDialogButton } from '@speckle/ui-components'
graphql(`
fragment AutomateFunctionPageParametersDialog_AutomateFunctionRelease on AutomateFunctionRelease {
@@ -46,4 +46,12 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { required: true })
const finalParams = computed(() => formatVersionParams(props.release.inputSchema))
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Close',
props: { color: 'outline' },
onClick: () => (open.value = false)
}
])
</script>
@@ -1,28 +1,32 @@
<template>
<div>
<div
class="pt-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
>
<Portal to="navigation">
<HeaderNavLink :to="automationFunctionsRoute" :name="'Automate functions'" />
</Portal>
<h1 class="text-heading-xl">Automate functions</h1>
<div class="flex flex-col sm:flex-row gap-2">
<Portal to="navigation">
<template v-if="!!workspace?.slug && !!workspace?.name">
<HeaderNavLink
:to="workspaceRoute(workspace.slug)"
:name="workspace.name"
:separator="false"
/>
</template>
<HeaderNavLink
:to="workspaceFunctionsRoute(workspace?.slug!)"
name="Functions"
:separator="!!workspace"
/>
</Portal>
<div class="flex flex-col md:flex-row gap-y-2 md:gap-x-4 md:justify-between">
<div class="w-full flex flex-row justify-between gap-2">
<FormTextInput
name="search"
placeholder="Search functions..."
placeholder="Search..."
show-clear
color="foundation"
class="grow"
v-bind="bind"
v-on="on"
/>
<FormButton
v-if="canCreateFunction"
:icon-left="PlusIcon"
@click="() => (createDialogOpen = true)"
>
New Function
<FormButton :disabled="!canCreateFunction" @click="createDialogOpen = true">
New function
</FormButton>
</div>
</div>
@@ -31,21 +35,27 @@
:is-authorized="!!activeUser?.automateInfo.hasAutomateGithubApp"
:github-orgs="activeUser?.automateInfo.availableGithubOrgs || []"
:templates="availableTemplates"
:workspace="workspace"
/>
</div>
</template>
<script setup lang="ts">
import { PlusIcon } from '@heroicons/vue/24/outline'
import type { Nullable, Optional } from '@speckle/shared'
import { Roles, type Nullable, type Optional } from '@speckle/shared'
import { useDebouncedTextInput } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql'
import type { AutomateFunctionsPageHeader_QueryFragment } from '~/lib/common/generated/gql/graphql'
import { automationFunctionsRoute } from '~/lib/common/helpers/route'
import type {
AutomateFunctionCreateDialog_WorkspaceFragment,
AutomateFunctionsPageHeader_QueryFragment
} from '~/lib/common/generated/gql/graphql'
import { workspaceFunctionsRoute, workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
graphql(`
fragment AutomateFunctionsPageHeader_Query on Query {
activeUser {
id
role
automateInfo {
hasAutomateGithubApp
availableGithubOrgs
@@ -64,6 +74,7 @@ graphql(`
const props = defineProps<{
activeUser: Optional<AutomateFunctionsPageHeader_QueryFragment['activeUser']>
serverInfo: Optional<AutomateFunctionsPageHeader_QueryFragment['serverInfo']>
workspace?: AutomateFunctionCreateDialog_WorkspaceFragment
}>()
const search = defineModel<string>('search')
@@ -71,15 +82,18 @@ const { on, bind } = useDebouncedTextInput({ model: search })
const { triggerNotification } = useGlobalToast()
const route = useRoute()
const router = useRouter()
const mixpanel = useMixpanel()
const createDialogOpen = ref(false)
const availableTemplates = computed(
() => props.serverInfo?.automate.availableFunctionTemplates || []
)
const canCreateFunction = computed(
() => !!props.activeUser?.id && !!availableTemplates.value.length
)
const canCreateFunction = computed(() => {
return props.workspace
? !!props.activeUser?.id && !!availableTemplates.value.length
: props.activeUser?.role === Roles.Server.Admin
})
if (import.meta.client) {
watch(
@@ -92,6 +106,7 @@ if (import.meta.client) {
type: ToastNotificationType.Success,
title: 'GitHub authorization successful'
})
mixpanel.track('Automate Finish Authorize GitHub App')
createDialogOpen.value = true
} else if (ghAuthVal === 'access_denied') {
triggerNotification({
@@ -114,5 +129,15 @@ if (import.meta.client) {
},
{ immediate: true }
)
watch(
() => route.query['automateBetaRedirect'] as Nullable<string>,
(isRedirect) => {
if (!isRedirect?.length) return
mixpanel.track('Automate Beta Visit Redirected')
const { automateBetaRedirect, ...query } = route.query
void router.replace({ query })
},
{ immediate: true }
)
}
</script>
@@ -18,7 +18,10 @@
</template>
<script setup lang="ts">
import { graphql } from '~/lib/common/generated/gql'
import type { AutomateFunctionsPageItems_QueryFragment } from '~/lib/common/generated/gql/graphql'
import type {
AutomateAutomationCreateDialog_AutomateFunctionFragment,
AutomationsFunctionsCard_AutomateFunctionFragment
} from '~/lib/common/generated/gql/graphql'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
defineEmits<{
@@ -28,7 +31,7 @@ defineEmits<{
graphql(`
fragment AutomateFunctionsPageItems_Query on Query {
automateFunctions(limit: 20, filter: { search: $search }, cursor: $cursor) {
automateFunctions(limit: 6, filter: { search: $search }, cursor: $cursor) {
totalCount
items {
id
@@ -41,10 +44,11 @@ graphql(`
`)
const props = defineProps<{
functions?: AutomateFunctionsPageItems_QueryFragment
functions?: (AutomationsFunctionsCard_AutomateFunctionFragment &
AutomateAutomationCreateDialog_AutomateFunctionFragment)[]
search?: boolean
loading?: boolean
}>()
const fns = computed(() => props.functions?.automateFunctions.items || [])
const fns = computed(() => props.functions || [])
</script>
@@ -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>
@@ -2,7 +2,7 @@
<div>
<LayoutTable
:columns="[
{ id: 'status', header: 'status', classes: 'col-span-2' },
{ id: 'status', header: 'Status', classes: 'col-span-2' },
{ id: 'runId', header: 'Run ID', classes: 'col-span-3' },
{ id: 'modelVersion', header: 'Model version', classes: 'col-span-3' },
{ id: 'date', header: 'Date', classes: 'col-span-2' },
@@ -13,8 +13,7 @@
{
icon: EyeIcon,
label: 'View',
action: onView,
class: '!text-primary'
action: onView
}
]"
empty-message="Automation does not have any runs"
@@ -3,17 +3,15 @@
<template #header>
<div class="flex flex-col">
<div class="flex items-center space-x-2 max-w-full w-full">
<div class="mt-[6px] shrink-0">
<AutomateRunsTriggerStatusIcon
:summary="summary"
class="h-6 w-6 sm:h-10 sm:w-10"
/>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h4 :class="[`h6 sm:h5 font-medium whitespace-normal`, summary.titleColor]">
<AutomateRunsTriggerStatusIcon
:summary="summary"
class="h-6 w-6 sm:h-10 sm:w-10"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<h4 class="text-heading-sm" :class="[summary.titleColor]">
{{ summary.title }}
</h4>
<div class="caption text-foreground-2 whitespace-normal">
<div class="text-body-2xs text-foreground-2">
{{ summary.longSummary }}
</div>
</div>
@@ -40,7 +38,7 @@
to="https://speckle.systems/blog/automate-with-speckle/"
class="order-2 sm:order-1"
>
Learn more about Automate here!
Learn more about Automate
</FormButton>
<div
class="flex w-full justify-between order-1 sm:order-2 sm:justify-normal sm:w-auto sm:space-x-1"
@@ -1,95 +1,99 @@
<template>
<div>
<div
class="flex flex-col gap-y-2 sm:flex-row sm:gap-y-0 sm:gap-x-2 sm:items-center my-2 py-1 px-2 border border-blue-500/10 rounded-md sm:h-12"
class="flex flex-col lg:flex-row gap-y-1 my-2 py-2 px-2 border border-outline-2 rounded-md lg:items-center"
>
<div class="flex gap-x-2 items-center truncate">
<div>
<div class="flex flex-col lg:flex-row gap-2 flex-1">
<div class="flex items-center gap-x-1.5">
<Component
:is="statusMetaData.icon"
v-tippy="functionRun.status"
:class="['h-5 w-5 outline-none', statusMetaData.iconColor]"
:class="['h-4 w-4 outline-none', statusMetaData.iconColor]"
/>
<AutomateFunctionLogo :logo="functionRun.function?.logo" size="xs" />
</div>
<AutomateFunctionLogo :logo="functionRun.function?.logo" size="xs" />
<div class="font-medium text-sm truncate">
{{ automationName ? automationName + ' / ' : ''
}}{{ functionRun.function?.name || 'Unknown function' }}
</div>
</div>
<div class="flex-1 flex flex-col gap-y-1">
<div class="font-medium text-body-2xs truncate">
{{ automationName ? automationName + ' / ' : ''
}}{{ functionRun.function?.name || 'Unknown function' }}
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center truncate">
<div class="sm:truncate">
<div
v-if="
[
AutomateRunStatus.Initializing,
AutomateRunStatus.Running,
AutomateRunStatus.Pending
].includes(functionRun.status)
"
class="text-sm text-foreground-2 italic whitespace-normal sm:truncate"
>
Function is {{ functionRun.status.toLowerCase() }}.
</div>
<div
v-else
class="text-sm text-foreground-2 italic whitespace-normal sm:truncate"
>
{{ functionRun.statusMessage }}
</div>
</div>
<div
class="flex flex-grow text-right flex-shrink-0 bg-pink-300/0 justify-end space-x-2 items-center"
>
<div
v-if="attachments && attachments.length !== 0"
class="flex space-x-1 shrink items-center"
>
<div v-if="attachments.length === 1">
<AutomateRunsAttachmentButton
:blob-id="attachments[0]"
:project-id="projectId"
size="xs"
/>
</div>
<FormButton
v-if="attachments.length > 1"
size="sm"
color="outline"
class="mt-1"
@click="showAttachmentDialog = true"
>
{{ attachments.length }} attachments
</FormButton>
<LayoutDialog
v-model:open="showAttachmentDialog"
:title="`${functionRun.function?.name || 'Unknown function'} attachments`"
max-width="sm"
>
<div v-for="id in attachments" :key="id">
<AutomateRunsAttachmentButton
:blob-id="id"
:restrict-width="false"
:project-id="projectId"
size="xs"
/>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center truncate">
<div class="sm:truncate">
<div
v-if="
[
AutomateRunStatus.Initializing,
AutomateRunStatus.Running,
AutomateRunStatus.Pending
].includes(functionRun.status)
"
class="text-body-2xs text-foreground-2 italic whitespace-normal sm:truncate"
>
Function is {{ functionRun.status.toLowerCase() }}.
</div>
</LayoutDialog>
</div>
<div class="flex-shrink-0">
<FormButton
v-if="functionRun.contextView"
size="sm"
:to="functionRun.contextView"
target="_blank"
<div
v-else
class="text-body-2xs text-foreground-2 italic whitespace-normal sm:truncate"
>
{{ functionRun.statusMessage }}
</div>
</div>
<div
class="flex flex-grow text-right flex-shrink-0 bg-pink-300/0 justify-end space-x-2 items-center"
>
View Results
</FormButton>
<div
v-if="attachments && attachments.length !== 0"
class="flex space-x-1 shrink items-center"
>
<div v-if="attachments.length === 1">
<AutomateRunsAttachmentButton
:blob-id="attachments[0]"
:project-id="projectId"
size="xs"
/>
</div>
<FormButton
v-if="attachments.length > 1"
size="sm"
color="outline"
class="mt-1"
@click="showAttachmentDialog = true"
>
{{ attachments.length }} attachments
</FormButton>
<LayoutDialog
v-model:open="showAttachmentDialog"
:title="`${
functionRun.function?.name || 'Unknown function'
} attachments`"
max-width="sm"
>
<div v-for="id in attachments" :key="id">
<AutomateRunsAttachmentButton
:blob-id="id"
:restrict-width="false"
:project-id="projectId"
size="xs"
/>
</div>
</LayoutDialog>
</div>
</div>
</div>
</div>
</div>
<FormButton
v-if="functionRun.contextView"
size="sm"
color="outline"
:to="functionRun.contextView"
target="_blank"
>
View results
</FormButton>
</div>
</div>
</template>
@@ -3,15 +3,13 @@
<ViewerLayoutPanel @close="$emit('close')">
<template #title>Automate</template>
<div class="flex items-center space-x-2 w-full pl-3 mt-2">
<div class="mt-[6px] shrink-0">
<AutomateRunsTriggerStatusIcon :summary="summary" class="h-6 w-6" />
</div>
<div class="flex min-w-0 flex-col gap-1">
<h4 :class="[`label font-medium whitespace-normal`, summary.titleColor]">
<div class="flex items-center space-x-2 w-full pl-3 mt-3 mb-1">
<AutomateRunsTriggerStatusIcon :summary="summary" class="h-6 w-6" />
<div class="flex min-w-0 flex-col gap-0.5">
<h4 class="text-heading-sm" :class="[summary.titleColor]">
{{ summary.title }}
</h4>
<div class="caption text-foreground-2 whitespace-normal">
<div class="text-body-2xs text-foreground-2">
{{ summary.longSummary }}
</div>
</div>
@@ -1,11 +1,9 @@
<template>
<div
:class="`border border-blue-500/10 rounded-md space-y-2 overflow-hidden ${
expanded ? 'shadow' : ''
}`"
class="border border-outline-2 rounded-md space-y-2 overflow-hidden bg-foundation shadow-sm"
>
<button
class="flex space-x-1 items-center max-w-full w-full px-1 py-1 h-8 transition hover:bg-primary-muted"
class="flex space-x-1.5 items-center max-w-full w-full px-2 py-1 h-8 transition hover:bg-primary-muted bg-foundation"
@click="expanded = !expanded"
>
<div>
@@ -16,7 +14,7 @@
/>
</div>
<AutomateFunctionLogo :logo="functionRun.function?.logo" size="xs" />
<div class="font-medium text-xs truncate">
<div class="font-medium text-body-2xs truncate">
{{ automationName ? automationName + ' / ' : ''
}}{{ functionRun.function?.name || 'Unknown function' }}
</div>
@@ -31,33 +29,21 @@
</button>
</div>
</button>
<div v-if="expanded" class="px-2 pb-2 space-y-4">
<div v-if="expanded" class="px-3 pb-2 space-y-4">
<!-- Status message -->
<div class="space-y-1">
<div class="text-xs font-medium text-foreground-2">Status</div>
<div
v-if="
[
AutomateRunStatus.Initializing,
AutomateRunStatus.Running,
AutomateRunStatus.Pending
].includes(functionRun.status)
"
class="text-xs text-foreground-2 italic"
>
Function is {{ functionRun.status.toLowerCase() }}.
</div>
<div v-else class="text-xs text-foreground-2 italic">
{{ functionRun.statusMessage || 'No status message' }}
<div class="text-body-2xs font-medium text-foreground">Status</div>
<div class="text-body-2xs text-foreground-2 whitespace-pre-wrap">
{{ statusMessage }}
</div>
</div>
<!-- Attachments -->
<div
v-if="attachments.length !== 0"
class="border-t pt-2 border-foreground-2 space-y-1"
class="border-t pt-2 border-outline-2 space-y-1"
>
<div class="text-xs font-medium text-foreground-2">Attachments</div>
<div class="text-body-2xs font-medium text-foreground-2">Attachments</div>
<div class="ml-[2px] justify-start">
<AutomateRunsAttachmentButton
v-for="id in attachments"
@@ -71,11 +57,11 @@
</div>
</div>
<!-- Context view -->
<div v-if="hasValidContextView" class="border-t pt-2 border-foreground-2">
<div v-if="hasValidContextView" class="border-t pt-2 border-outline-2">
<div>
<FormButton
size="sm"
link
color="outline"
class="truncate max-w-full"
:to="functionRun.contextView || ''"
>
@@ -86,9 +72,9 @@
<!-- Results -->
<div
v-if="!!results?.values.objectResults.length"
class="border-t pt-2 border-foreground-2"
class="border-t pt-2 border-outline-2"
>
<div class="text-xs font-medium text-foreground-2 mb-2">Results</div>
<div class="text-body-2xs font-medium text-foreground-2 mb-2">Results</div>
<div class="space-y-1">
<AutomateViewerPanelFunctionRunRowObjectResult
v-for="(result, index) in results.values.objectResults.slice(
@@ -168,4 +154,16 @@ const hasValidContextView = computed(() => {
const currentPath = route.fullPath
return !doesRouteFitTarget(ctxView, currentPath)
})
const statusMessage = computed(() => {
const isFinished = ![
AutomateRunStatus.Initializing,
AutomateRunStatus.Running,
AutomateRunStatus.Pending
].includes(props.functionRun.status)
return isFinished
? props.functionRun.statusMessage ?? 'No status message'
: `Function is ${props.functionRun.status.toLowerCase()}.`
})
</script>
@@ -1,7 +1,7 @@
<template>
<div :class="`overflow-hidden`">
<div class="overflow-hidden">
<button
:class="`block transition text-left hover:bg-primary-muted hover:shadow-md rounded-md p-1 cursor-pointer border-l-2 ${
:class="`block w-full transition text-left hover:bg-primary-muted hover:shadow-md rounded-md p-1 cursor-pointer border-l-2 ${
isIsolated || metadataGradientIsSet
? 'border-primary bg-primary-muted shadow-md'
: 'border-transparent'
@@ -30,6 +30,7 @@
</template>
<script setup lang="ts">
import {
CheckIcon,
XMarkIcon,
InformationCircleIcon,
ExclamationTriangleIcon
@@ -154,6 +155,11 @@ const setOrUnsetGradient = () => {
const iconAndColor = computed(() => {
switch (props.result.level) {
case 'SUCCESS':
return {
icon: CheckIcon,
color: 'text-success font-medium'
}
case 'ERROR':
return {
icon: XMarkIcon,
@@ -0,0 +1,160 @@
<template>
<div>
<template v-if="!hasValidPlan">
<div
v-if="condensed"
class="flex items-center justify-between rounded-md p-2 pl-3 text-body-3xs font-medium bg-info-lighter text-primary-focus gap-x-2"
>
{{ title }}
<FormButton
v-if="actions.length > 0"
size="sm"
:disabled="actions[0].disabled"
@click="actions[0].onClick"
>
{{ actions[0].title }}
</FormButton>
</div>
<CommonAlert v-else :color="alertColor" :actions="actions">
<template #title>
{{ title }}
</template>
<template #description>
{{ description }}
</template>
</CommonAlert>
</template>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~/lib/common/generated/gql'
import {
type BillingAlert_WorkspaceFragment,
WorkspacePlanStatuses
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { AlertAction, AlertColor } from '@speckle/ui-components'
graphql(`
fragment BillingAlert_Workspace on Workspace {
id
plan {
name
status
createdAt
}
subscription {
billingInterval
currentBillingCycleEnd
}
}
`)
const props = defineProps<{
workspace: BillingAlert_WorkspaceFragment
actions?: Array<AlertAction>
condensed?: boolean
}>()
const { billingPortalRedirect } = useBillingActions()
const planStatus = computed(() => props.workspace.plan?.status)
// If there is no plan status, we assume it's a trial
const isTrial = computed(
() => !planStatus.value || planStatus.value === WorkspacePlanStatuses.Trial
)
const isPaymentFailed = computed(
() => planStatus.value === WorkspacePlanStatuses.PaymentFailed
)
const isScheduledForCancelation = computed(
() => planStatus.value === WorkspacePlanStatuses.CancelationScheduled
)
const trialDaysLeft = computed(() => {
const createdAt = props.workspace.plan?.createdAt
const trialEndDate = dayjs(createdAt).add(31, 'days')
const diffDays = trialEndDate.diff(dayjs(), 'day')
return Math.max(0, diffDays)
})
const title = computed(() => {
if (isTrial.value) {
if (trialDaysLeft.value === 0) {
return 'Final day of free trial'
}
if (props.condensed) {
return `${trialDaysLeft.value} day${
trialDaysLeft.value !== 1 ? 's' : ''
} left in trial`
} else
return `You have ${trialDaysLeft.value} day${
trialDaysLeft.value !== 1 ? 's' : ''
} left on your free trial`
}
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
return `Your workspace subscription is scheduled for cancellation`
case WorkspacePlanStatuses.Canceled:
return `Your workspace subscription has been cancelled`
case WorkspacePlanStatuses.Expired:
return `Your free trial has ended`
case WorkspacePlanStatuses.PaymentFailed:
return "Your last payment didn't go through"
default:
return ''
}
})
const description = computed(() => {
if (isTrial.value) {
return trialDaysLeft.value === 0
? 'Upgrade to a paid plan to continue using your workspace'
: 'Upgrade to a paid plan to start your subscription'
}
switch (planStatus.value) {
case WorkspacePlanStatuses.CancelationScheduled:
return 'Once the current billing cycle ends your workspace will enter read-only mode. Renew your subscription to undo.'
case WorkspacePlanStatuses.Canceled:
return 'Your workspace has been cancelled and is in read-only mode. Subscribe to a plan to regain full access.'
case WorkspacePlanStatuses.Expired:
return "The workspace is in a read-only locked state until there's an active subscription. Subscribe to a plan to regain full access."
case WorkspacePlanStatuses.PaymentFailed:
return "Update your payment information now to ensure your workspace doesn't go into maintenance mode."
default:
return ''
}
})
const hasValidPlan = computed(() => planStatus.value === WorkspacePlanStatuses.Valid)
const alertColor = computed<AlertColor>(() => {
switch (planStatus.value) {
case WorkspacePlanStatuses.PaymentFailed:
case WorkspacePlanStatuses.Canceled:
return 'danger'
case WorkspacePlanStatuses.CancelationScheduled:
case WorkspacePlanStatuses.Expired:
return 'warning'
default:
return 'neutral'
}
})
const actions = computed((): AlertAction[] => {
const actions: Array<AlertAction> = props.actions ?? []
if (isPaymentFailed.value) {
actions.push({
title: 'Update payment information',
onClick: () => billingPortalRedirect(props.workspace.id),
disabled: !props.workspace.id
})
} else if (isScheduledForCancelation.value) {
actions.push({
title: 'Renew subscription',
onClick: () => billingPortalRedirect(props.workspace.id),
disabled: !props.workspace.id
})
}
return actions
})
</script>
@@ -1,59 +0,0 @@
<template>
<ul>
<li
v-for="(item, index) in billingItems"
:key="item.name"
class="text-body-xs flex"
:class="[index === billingItems.length - 1 ? 'border-b border-outline-3' : null]"
>
<p
class="text-foreground flex-1 py-2 px-3"
:class="[index < billingItems.length - 1 ? 'border-b border-outline-3' : null]"
>
{{ item.label }}
<span class="text-foreground-2">x</span>
£{{ item.cost }}
</p>
<p
class="text-right text-foreground ml-4 w-32 md:w-40 py-2 px-3"
:class="[index < billingItems.length - 1 ? 'border-b border-outline-3' : null]"
>
£{{ item.count * item.cost }} / month
</p>
</li>
<li class="flex justify-between text-foreground font-medium">
<p class="flex-1 p-3">Total</p>
<p class="text-right w-32 md:w-40 ml-4 p-3">
£{{ workspaceCost.subTotal }} / month
</p>
</li>
</ul>
</template>
<script lang="ts" setup>
import { graphql } from '~/lib/common/generated/gql'
import type { BillingSummary_WorkspaceCostFragment } from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment BillingSummary_WorkspaceCost on WorkspaceCost {
items {
cost
count
name
label
}
discount {
amount
name
}
subTotal
total
}
`)
const props = defineProps<{
workspaceCost: BillingSummary_WorkspaceCostFragment
}>()
const billingItems = computed(() => props.workspaceCost.items)
</script>
+17 -12
View File
@@ -4,7 +4,7 @@
<slot name="icon" />
</div>
<div class="flex-1">
<div v-if="title || description" class="flex-1">
<div v-if="title" class="flex items-center gap-2">
<p class="text-heading-sm text-foreground">{{ title }}</p>
<CommonBadge v-if="badge" rounded>{{ badge }}</CommonBadge>
@@ -21,20 +21,25 @@
v-if="buttons"
class="flex flex-col flex-wrap md:flex-row gap-y-2 md:gap-x-2 gap-y-0 mt-3"
>
<FormButton
<div
v-for="(button, index) in buttons"
:key="button.id || index"
v-bind="button.props || {}"
:disabled="button.props?.disabled || button.disabled"
:submit="button.props?.submit || button.submit"
target="_blank"
external
size="sm"
color="outline"
@click="($event) => button.onClick?.($event)"
v-tippy="button.disabledMessage"
class="shrink-0"
>
{{ button.text }}
</FormButton>
<FormButton
v-bind="button.props || {}"
:disabled="button.props?.disabled || button.disabled"
:submit="button.props?.submit || button.submit"
target="_blank"
external
size="sm"
color="outline"
@click="($event) => button.onClick?.($event)"
>
{{ button.text }}
</FormButton>
</div>
</div>
</div>
</template>
@@ -14,7 +14,7 @@
:icon-left="ClipboardDocumentIcon"
hide-text
@click="onCopy"
></FormButton>
/>
</div>
</template>
<script setup lang="ts">
@@ -1,11 +1,9 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
v-if="cleanReadmeHtml.length"
:class="proseClasses"
v-html="cleanReadmeHtml"
></div>
<div v-else class="italic text-center">No readme found</div>
<div v-if="cleanReadmeHtml.length" :class="proseClasses" v-html="cleanReadmeHtml" />
<div v-else class="text-foreground-2 text-body-xs text-center italic">
No readme found
</div>
</template>
<script setup lang="ts">
import type { Nullable } from '@speckle/shared'
@@ -1,55 +0,0 @@
<template>
<div class="bg-foundation text-foreground rounded shadow-md p-2">
<CommonTiptapMentionListItem
v-if="existingUser"
:item="existingUser"
is-selected
@click="enterHandler"
/>
<FormButton v-else @click="enterHandler">Invite {{ query }}</FormButton>
</div>
</template>
<script setup lang="ts">
import type {
SuggestionCommandProps,
SuggestionKeyDownProps
} from '~~/lib/core/tiptap/email-mention/suggestion'
import type { SuggestionOptionsItem } from '~~/lib/core/tiptap/mentionExtension'
const props = defineProps<{
query: string
items: SuggestionOptionsItem[]
command: (mention: SuggestionCommandProps) => void
}>()
const existingUser = computed(() => props.items[0] || null)
const enterHandler = () => {
if (existingUser.value) {
// Create a mention of the existing user
props.command({
mention: { id: existingUser.value.id, label: existingUser.value.name },
email: null
})
} else {
// Trigger invite and close popup
props.command({
email: props.query,
mention: null
})
}
}
const onKeyDown = (params: SuggestionKeyDownProps) => {
const { event } = params
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
defineExpose({
onKeyDown
})
</script>
@@ -1,6 +1,6 @@
<template>
<div
v-show="(query?.length || 0) >= 3"
v-show="(query?.length || 0) >= 1"
class="bg-foundation text-foreground rounded shadow-md p-2"
>
<ul>
@@ -14,7 +14,7 @@
</li>
</template>
<template v-else>
<li>Couldn't find anything 🤷</li>
<li>Couldn't find anyone</li>
</template>
</ul>
</div>
@@ -44,13 +44,9 @@ const props = defineProps<{
placeholder?: string
readonly?: boolean
/**
* Used to invite users to project when their emails are mentioned
* Used to scope things like user mentions to project collaborators etc.
*/
projectId?: string
/**
* Disable invitation CTA, e.g. if user doesn't have the required accesses
*/
disableInvitationCta?: boolean
}>()
const editorContentRef = ref(null as Nullable<HTMLElement>)
@@ -65,8 +61,7 @@ const editor = new Editor({
editable: isEditable.value,
extensions: getEditorExtensions(props.schemaOptions, {
placeholder: props.placeholder,
projectId:
props.projectId && !props.disableInvitationCta ? props.projectId : undefined
projectId: props.projectId
}),
onUpdate: () => {
const data = getData()
@@ -28,7 +28,7 @@
>
<WorkspaceAvatar
:logo="project.workspace.logo"
:default-logo-index="project.workspace.defaultLogoIndex"
:name="project.workspace.name"
size="sm"
/>
<p class="text-body-2xs text-foreground ml-2">
@@ -77,7 +77,7 @@ graphql(`
id
slug
name
...WorkspaceAvatar_Workspace
logo
}
}
`)
@@ -58,40 +58,44 @@
v-if="isWorkspacesEnabled"
collapsible
title="Workspaces"
:plus-click="isNotGuest ? handlePlusClick : undefined"
plus-text="Create workspace"
:icon-click="isNotGuest ? handlePlusClick : undefined"
icon-text="Create workspace"
>
<NuxtLink :to="workspacesRoute" @click="handleIntroducingWorkspacesClick">
<LayoutSidebarMenuGroupItem
v-if="!hasWorkspaces || route.path === workspacesRoute"
label="Introducing workspaces"
:active="isActive(workspacesRoute)"
tag="BETA"
>
<template #icon>
<IconWorkspaces class="size-4 text-foreground-2" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<NuxtLink
v-for="(item, key) in workspacesItems"
:key="key"
:to="item.to"
@click="isOpenMobile = false"
>
<LayoutSidebarMenuGroupItem
:label="item.label"
:active="isActive(item.to)"
class="!pl-1"
<template v-for="(item, key) in workspacesItems" :key="key">
<NuxtLink
v-if="item.creationState.completed !== false"
:to="item.to"
@click="isOpenMobile = false"
>
<template #icon>
<WorkspaceAvatar
:logo="item.logo"
:default-logo-index="item.defaultLogoIndex"
size="sm"
/>
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
<LayoutSidebarMenuGroupItem
:label="item.label"
:active="isActive(item.to)"
:tag="
item.plan.status === WorkspacePlanStatuses.Trial ||
item.plan.status === WorkspacePlanStatuses.Expired ||
!item.plan.status
? 'TRIAL'
: undefined
"
class="!pl-1"
>
<template #icon>
<WorkspaceAvatar :name="item.name" :logo="item.logo" size="sm" />
</template>
</LayoutSidebarMenuGroupItem>
</NuxtLink>
</template>
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup title="Resources" collapsible>
@@ -152,32 +156,17 @@
</NuxtLink>
</LayoutSidebarMenuGroup>
</LayoutSidebarMenu>
<template #promo>
<LayoutSidebarPromo
title="SpeckleCon 2024"
text="Join us in London on Nov 13-14 for the ultimate community event."
button-text="Get tickets"
@on-click="onPromoClick"
/>
</template>
</LayoutSidebar>
</div>
</template>
<FeedbackDialog v-model:open="showFeedbackDialog" />
<WorkspaceCreateDialog
v-model:open="showWorkspaceCreateDialog"
navigate-on-success
event-source="sidebar"
/>
</div>
</template>
<script setup lang="ts">
import {
FormButton,
LayoutSidebar,
LayoutSidebarPromo,
LayoutSidebarMenu,
LayoutSidebarMenuGroup,
LayoutSidebarMenuGroupItem
@@ -189,13 +178,30 @@ import {
projectsRoute,
workspaceRoute,
workspacesRoute,
downloadManagerUrl
downloadManagerUrl,
workspaceCreateRoute
} from '~/lib/common/helpers/route'
import { useRoute } from 'vue-router'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { HomeIcon } from '@heroicons/vue/24/outline'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { Roles } from '@speckle/shared'
import { graphql } from '~/lib/common/generated/gql'
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
graphql(`
fragment Sidebar_User on User {
id
automateFunctions {
items {
id
name
description
logo
}
}
}
`)
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
@@ -205,7 +211,6 @@ const { activeUser: user } = useActiveUser()
const mixpanel = useMixpanel()
const isOpenMobile = ref(false)
const showWorkspaceCreateDialog = ref(false)
const showFeedbackDialog = ref(false)
const { result: workspaceResult, onResult: onWorkspaceResult } = useQuery(
@@ -223,18 +228,24 @@ const isActive = (...routes: string[]): boolean => {
const isNotGuest = computed(
() => Roles.Server.Admin || user.value?.role === Roles.Server.User
)
const workspacesItems = computed(() =>
workspaceResult.value?.activeUser
? workspaceResult.value.activeUser.workspaces.items.map((workspace) => ({
label: workspace.name,
name: workspace.name,
id: workspace.id,
to: workspaceRoute(workspace.slug),
logo: workspace.logo,
defaultLogoIndex: workspace.defaultLogoIndex
plan: {
status: workspace.plan?.status
},
creationState: {
completed: workspace.creationState?.completed
}
}))
: []
)
const hasWorkspaces = computed(() => workspacesItems.value.length > 0)
onWorkspaceResult((result) => {
if (result.data?.activeUser) {
@@ -248,22 +259,13 @@ onWorkspaceResult((result) => {
}
})
const onPromoClick = () => {
mixpanel.track('Promo Banner Clicked', {
source: 'sidebar',
campaign: 'specklecon2024'
})
window.open('https://conf.speckle.systems/', '_blank')
}
const openFeedbackDialog = () => {
showFeedbackDialog.value = true
isOpenMobile.value = false
}
const openWorkspaceCreateDialog = () => {
showWorkspaceCreateDialog.value = true
navigateTo(workspaceCreateRoute())
mixpanel.track('Create Workspace Button Clicked', {
source: 'sidebar'
})
@@ -1,41 +1,38 @@
<template>
<NuxtLink :to="tutorial.url" target="_blank">
<NuxtLink :to="tutorialItem.url" target="_blank" @click="trackClick">
<div
class="bg-foundation border border-outline-3 rounded-xl flex flex-col overflow-hidden hover:border-outline-5 transition"
>
<div
:style="{ backgroundImage: `url(${tutorial.featureImage})` }"
class="bg-foundation-page bg-cover bg-center w-full h-32"
<NuxtImg
:src="tutorialItem.image"
:alt="tutorialItem.title"
class="h-32 w-full object-cover"
width="400"
height="225"
/>
<div class="p-5 pb-4">
<h3 v-if="tutorial.title" class="text-body-2xs text-foreground truncate">
{{ tutorial.title }}
<div class="p-5">
<h3 class="text-body-2xs text-foreground truncate">
{{ tutorialItem.title }}
</h3>
<p class="text-body-3xs text-foreground-2 mt-2">
<span v-tippy="updatedAt.full">
{{ updatedAt.relative }}
</span>
<template v-if="tutorial.readingTime">
<span class="pl-1 pr-2"></span>
{{ tutorial.readingTime }}m read
</template>
</p>
</div>
</div>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { TutorialItem } from '~~/lib/dashboard/helpers/types'
import type { TutorialItem } from '~/lib/dashboard/helpers/types'
import { useMixpanel } from '~~/lib/core/composables/mp'
const mixpanel = useMixpanel()
const props = defineProps<{
tutorial: TutorialItem
tutorialItem: TutorialItem
}>()
const updatedAt = computed(() => {
return {
full: formattedFullDate(props.tutorial.publishedAt),
relative: formattedRelativeDate(props.tutorial.publishedAt, { capitalize: true })
}
})
const trackClick = () => {
mixpanel.track('Tutorial clicked', {
title: props.tutorialItem.title,
url: props.tutorialItem.url
})
}
</script>
@@ -1,15 +1,14 @@
<template>
<LayoutDialog
v-model:open="isOpen"
title="Give us feedback"
:title="dialogTitle"
:buttons="dialogButtons"
:on-submit="onSubmit"
max-width="md"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-body-xs text-foreground font-medium">
How can we improve Speckle? If you have a feature request, please also share how
you would use it and why it's important to you
{{ dialogIntro }}
</p>
<FormTextArea
v-model="feedback"
@@ -18,6 +17,13 @@
label="Feedback"
color="foundation"
/>
<p v-if="!hideSuppport" class="text-body-xs !leading-4">
Need help? For support, head over to our
<FormButton to="https://speckle.community/" target="_blank" link text>
community forum
</FormButton>
where we can chat and solve problems together.
</p>
</div>
</LayoutDialog>
</template>
@@ -30,9 +36,24 @@ import { useZapier } from '~/lib/core/composables/zapier'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import { isRequired } from '~/lib/common/helpers/validation'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { defaultZapierWebhookUrl } from '~/lib/common/helpers/route'
type FeedbackType = 'general' | 'gendo'
type FormValues = { feedback: string }
const props = withDefaults(
defineProps<{
type?: FeedbackType
title?: string
intro?: string
hideSuppport?: boolean
metadata?: Record<string, unknown>
}>(),
{
type: 'general'
}
)
const isOpen = defineModel<boolean>('open', { required: true })
const { activeUser: user } = useActiveUser()
@@ -52,6 +73,14 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
}
])
const dialogTitle = computed(() => props.title || 'Give us feedback')
const dialogIntro = computed(
() =>
props.intro ||
'How can we improve Speckle? If you have a feature request, please also share how you would use it and why its important to you'
)
const onSubmit = handleSubmit(async () => {
if (!feedback.value) return
@@ -63,10 +92,12 @@ const onSubmit = handleSubmit(async () => {
})
mixpanel.track('Feedback Sent', {
message: feedback.value
message: feedback.value,
feedbackType: props.type,
...props.metadata
})
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
await sendWebhook(defaultZapierWebhookUrl, {
userId: user.value?.id ?? '',
feedback: feedback.value
})
@@ -2,7 +2,6 @@
<FormSelectBase
v-model="selectedValue"
:items="roles"
:multiple="multiple"
:clearable="clearable"
name="projectRoles"
label="Project roles"
@@ -14,9 +13,7 @@
:allow-unset="allowUnset"
:disabled="disabled"
>
<template #nothing-selected>
{{ multiple ? 'Select roles' : 'Select role' }}
</template>
<template #nothing-selected>Select role</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(value)">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
@@ -63,7 +60,6 @@ const emit = defineEmits<{
}>()
const props = defineProps<{
multiple?: boolean
modelValue?: ValueType
clearable?: boolean
disabledItems?: StreamRoles[]
@@ -133,6 +133,12 @@ const props = defineProps({
ownedOnly: {
type: Boolean,
default: false
},
/**
* Whether to only return projects within a specific workspace
*/
workspaceId: {
type: String as PropType<Optional<string>>
}
})
@@ -155,10 +161,11 @@ const invokeSearch = async (search: string) => {
if (!isLoggedIn.value) return []
const results = await apollo.query({
query: searchProjectsQuery,
variables: {
variables: computed(() => ({
search: search.trim().length ? search : null,
onlyWithRoles: props.ownedOnly ? [Roles.Stream.Owner] : null
}
onlyWithRoles: props.ownedOnly ? [Roles.Stream.Owner] : null,
...(props.workspaceId && { workspaceId: props.workspaceId })
})).value
})
return results.data.activeUser?.projects.items || []
}

Some files were not shown because too many files have changed in this diff Show More