diff --git a/.github/workflows/build-converter.yml b/.github/workflows/build-converter.yml
index ca1f6d81..60fce827 100644
--- a/.github/workflows/build-converter.yml
+++ b/.github/workflows/build-converter.yml
@@ -27,6 +27,6 @@ jobs:
with:
push: true
context: ./coordinates_converter
- tags: "ghcr.io/metacitytools/coordinates-converter:latest"
+ tags: "ghcr.io/stdio-cz/mc-coordinates-converter:latest"
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/build-studio.yml b/.github/workflows/build-studio.yml
index c0c9eab7..2155f30c 100644
--- a/.github/workflows/build-studio.yml
+++ b/.github/workflows/build-studio.yml
@@ -27,6 +27,6 @@ jobs:
with:
push: true
context: ./studio
- tags: "ghcr.io/metacitytools/studio:latest"
+ tags: "ghcr.io/stdio-cz/mc-studio:latest"
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 940231ee..ed4bc591 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,36 @@
+## [1.2.0](https://github.com/stdio-cz/MetacityStudioLegacy/compare/v1.1.0...v1.2.0) (2025-06-23)
+
+### Features
+
+* add save views api and change layout ([8e2569e](https://github.com/stdio-cz/MetacityStudioLegacy/commit/8e2569e082b224f043f76aa6574da6df294642f7))
+* add screenshot function and bookmark views (layout) ([596f0b6](https://github.com/stdio-cz/MetacityStudioLegacy/commit/596f0b635973cac11fa4af2a48484ba5fd39216f))
+* save views persisted and loaded correctly with zoom ([5f3c2d4](https://github.com/stdio-cz/MetacityStudioLegacy/commit/5f3c2d4a0d70c4bfc228b2a816d5d5e82baf83c4))
+* update tooltip rendering ([0201a90](https://github.com/stdio-cz/MetacityStudioLegacy/commit/0201a90e045e86b8774daf547a5220a5ef904921))
+## [1.1.0](https://github.com/stdio-cz/MetacityStudioLegacy/compare/v1.0.6...v1.1.0) (2025-06-10)
+
+### Features
+
+* add property only tooltip info to embeds (with persistence to DB and GUI) ([d54d01d](https://github.com/stdio-cz/MetacityStudioLegacy/commit/d54d01dc1ebd72ef04d75e99932c568af6c7284b))
+* allow hover over tooltip ([fc7e8c8](https://github.com/stdio-cz/MetacityStudioLegacy/commit/fc7e8c8d543d1c4b486c6726fc153cd44423d956))
+* format urls in tooltip ([16352f3](https://github.com/stdio-cz/MetacityStudioLegacy/commit/16352f3092ca5634574e9bb8ecf5810b2107128c))
+* in embed viewer keep just free camera and top view ([6925b1e](https://github.com/stdio-cz/MetacityStudioLegacy/commit/6925b1e84c51ef679ae6e00156bc4b1c7eebb1cb))
+* modify tooltip to show multiple column information and hide column selector ([e5a5b49](https://github.com/stdio-cz/MetacityStudioLegacy/commit/e5a5b493c5ed7dc4d2debdad87b5618914950cad))
+* remove active column dropdown if only one and forbid change of column name in the dropdown ([5e17be9](https://github.com/stdio-cz/MetacityStudioLegacy/commit/5e17be988a2d98d08f714f3ffc9ef962c30d6f79))
+## [1.0.6](https://github.com/stdio-cz/MetacityStudioLegacy/compare/v1.0.5...v1.0.6) (2025-02-03)
+
+### Bug Fixes
+
+* enable embeds viewing without authorization ([#4](https://github.com/stdio-cz/MetacityStudioLegacy/issues/4)) ([8dc5639](https://github.com/stdio-cz/MetacityStudioLegacy/commit/8dc5639754e6438076a9b31da4513f956a59b1c8))
+## [1.0.5](https://github.com/stdio-cz/MetacityStudioLegacy/compare/v1.0.4...v1.0.5) (2025-02-03)
+
+### Features
+
+* set timeout 5s for every toaster ([#1](https://github.com/stdio-cz/MetacityStudioLegacy/issues/1)) ([408640b](https://github.com/stdio-cz/MetacityStudioLegacy/commit/408640bdd63b66c1e3f3d04554450f4260bb8d9d))
+
+### Bug Fixes
+
+* add disableShift parameter while spliting models ([#2](https://github.com/stdio-cz/MetacityStudioLegacy/issues/2)) ([16dbefb](https://github.com/stdio-cz/MetacityStudioLegacy/commit/16dbefbf7a40fb98f056fbe570c5b30931d5055d))
+* changed hardcoded email address ([#3](https://github.com/stdio-cz/MetacityStudioLegacy/issues/3)) ([f545a4c](https://github.com/stdio-cz/MetacityStudioLegacy/commit/f545a4c59db88c502221bd0d8318845bb3372289))
## [1.0.4](https://github.com/MetacityTools/Studio/compare/v1.0.3...v1.0.4) (2024-09-20)
### Bug Fixes
diff --git a/README.md b/README.md
index 1b37f373..15767b7e 100644
--- a/README.md
+++ b/README.md
@@ -31,4 +31,17 @@ This repository is set up to be used with the Visual Studio Code Remote - Contai
The devcontainer includes:
* Node.js 20
* Python 3.12
-* Docker-in-Docker
\ No newline at end of file
+* Docker-in-Docker
+
+After running the devcontainer:
+
+```bash
+npm run migrations:run
+```
+
+In case of an error after logging in, for MacOS users, comment out
+
+
+```typescript
+migrations: Config.environment === "test" ? undefined : ["features/db/migrations/*.ts"],
+```
diff --git a/package-lock.json b/package-lock.json
index 5e418cd8..983a7841 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metacity",
- "version": "1.0.4",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metacity",
- "version": "1.0.4",
+ "version": "1.2.0",
"license": "ISC",
"devDependencies": {
"conventional-changelog-cli": "^5.0.0"
diff --git a/package.json b/package.json
index 4ef4e9a7..fae983f7 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "metacity",
"displayName": "MetaCity Studio",
"private": true,
- "version": "1.0.4",
+ "version": "1.2.0",
"description": "",
"scripts": {
"version": "npm run version:changelog && npm run version:sync && git add -A",
diff --git a/studio/app/api/embeds/route.ts b/studio/app/api/embeds/route.ts
index 6ca1bc5e..15ffe084 100644
--- a/studio/app/api/embeds/route.ts
+++ b/studio/app/api/embeds/route.ts
@@ -7,23 +7,50 @@ const postSchema = zfd.formData({
thumbnailFileContents: zfd.text(),
projectId: zfd.numeric(),
name: zfd.text(),
+ onlyTooltipInfo: zfd.checkbox().optional(),
+ savedViewIds: z.array(z.coerce.number()).optional(),
});
export async function POST(req: Request) {
try {
- const data = postSchema.parse(await req.formData());
+ const formData = await req.formData();
+ console.log("Received form data keys:", Array.from(formData.keys()));
+ console.log("savedViewIds values:", formData.getAll("savedViewIds"));
+ console.log("onlyTooltipInfo value:", formData.get("onlyTooltipInfo"));
+
+ const data = postSchema.parse(formData);
+ console.log("Parsed data:", {
+ projectId: data.projectId,
+ name: data.name,
+ onlyTooltipInfo: data.onlyTooltipInfo,
+ savedViewIds: data.savedViewIds,
+ });
+
const model = await createEmbed(
data.projectId,
data.name,
data.dataFile,
data.thumbnailFileContents,
+ data.onlyTooltipInfo ?? false,
+ Array.isArray(data.savedViewIds) ? data.savedViewIds : [],
);
return Response.json(model, { status: 201 });
} catch (e) {
if (e instanceof z.ZodError) {
- return new Response(e.message, { status: 400 });
+ console.error("Validation error:", e.errors);
+ return new Response(JSON.stringify({ error: "Validation failed", details: e.errors }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
}
- throw e;
+ console.error("Other error:", e);
+ return new Response(
+ JSON.stringify({ error: "Internal server error", message: e instanceof Error ? e.message : "Unknown error" }),
+ {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ },
+ );
}
}
diff --git a/studio/app/api/savedViews/[id]/route.ts b/studio/app/api/savedViews/[id]/route.ts
new file mode 100644
index 00000000..0943a94c
--- /dev/null
+++ b/studio/app/api/savedViews/[id]/route.ts
@@ -0,0 +1,80 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+import { injectRepository } from "@features/db/helpers";
+import { z } from "zod";
+
+const putSchema = z.object({
+ name: z.string().min(1).optional(),
+ cameraPosition: z.array(z.number()).length(3).optional(),
+ cameraTarget: z.array(z.number()).length(3).optional(),
+ projectionType: z.nativeEnum(ProjectionType).optional(),
+ fovYRadian: z.number().optional(),
+ orthographicLeft: z.number().optional(),
+ orthographicRight: z.number().optional(),
+ orthographicBottom: z.number().optional(),
+ orthographicTop: z.number().optional(),
+});
+
+export async function GET(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const savedViewRepository = await injectRepository(SavedView);
+ const savedView = await savedViewRepository.findOne({
+ where: { id: parseInt(params.id) },
+ });
+
+ if (!savedView) {
+ return new Response("Saved view not found", { status: 404 });
+ }
+
+ return Response.json(savedView);
+ } catch (e) {
+ console.error("Error fetching saved view:", e);
+ return new Response("Internal server error", { status: 500 });
+ }
+}
+
+export async function PUT(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const body = await req.json();
+ const data = putSchema.parse(body);
+
+ const savedViewRepository = await injectRepository(SavedView);
+ const savedView = await savedViewRepository.findOne({
+ where: { id: parseInt(params.id) },
+ });
+
+ if (!savedView) {
+ return new Response("Saved view not found", { status: 404 });
+ }
+
+ Object.assign(savedView, data);
+ const result = await savedViewRepository.save(savedView);
+
+ return Response.json(result);
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ return new Response(e.message, { status: 400 });
+ }
+ console.error("Error updating saved view:", e);
+ return new Response("Internal server error", { status: 500 });
+ }
+}
+
+export async function DELETE(req: Request, { params }: { params: { id: string } }) {
+ try {
+ const savedViewRepository = await injectRepository(SavedView);
+ const savedView = await savedViewRepository.findOne({
+ where: { id: parseInt(params.id) },
+ });
+
+ if (!savedView) {
+ return new Response("Saved view not found", { status: 404 });
+ }
+
+ await savedViewRepository.remove(savedView);
+ return new Response(null, { status: 204 });
+ } catch (e) {
+ console.error("Error deleting saved view:", e);
+ return new Response("Internal server error", { status: 500 });
+ }
+}
diff --git a/studio/app/api/savedViews/route.ts b/studio/app/api/savedViews/route.ts
new file mode 100644
index 00000000..b4a9c647
--- /dev/null
+++ b/studio/app/api/savedViews/route.ts
@@ -0,0 +1,69 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+import { injectRepository } from "@features/db/helpers";
+import { z } from "zod";
+
+const postSchema = z.object({
+ name: z.string().min(1),
+ cameraPosition: z.array(z.number()).length(3),
+ cameraTarget: z.array(z.number()).length(3),
+ projectionType: z.nativeEnum(ProjectionType),
+ fovYRadian: z.number(),
+ orthographicLeft: z.number(),
+ orthographicRight: z.number(),
+ orthographicBottom: z.number(),
+ orthographicTop: z.number(),
+ projectId: z.number(),
+});
+
+export async function GET(req: Request) {
+ try {
+ const { searchParams } = new URL(req.url);
+ const projectId = searchParams.get("projectId");
+
+ if (!projectId) {
+ return new Response("Project ID is required", { status: 400 });
+ }
+
+ const savedViewRepository = await injectRepository(SavedView);
+ const savedViews = await savedViewRepository.find({
+ where: { project: { id: parseInt(projectId) } },
+ order: { created_at: "DESC" },
+ });
+
+ return Response.json(savedViews);
+ } catch (e) {
+ console.error("Error fetching saved views:", e);
+ return new Response("Internal server error", { status: 500 });
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const body = await req.json();
+ const data = postSchema.parse(body);
+
+ const savedViewRepository = await injectRepository(SavedView);
+ const savedView = savedViewRepository.create({
+ name: data.name,
+ cameraPosition: data.cameraPosition,
+ cameraTarget: data.cameraTarget,
+ projectionType: data.projectionType,
+ fovYRadian: data.fovYRadian,
+ orthographicLeft: data.orthographicLeft,
+ orthographicRight: data.orthographicRight,
+ orthographicBottom: data.orthographicBottom,
+ orthographicTop: data.orthographicTop,
+ project: { id: data.projectId },
+ });
+
+ const result = await savedViewRepository.save(savedView);
+ return Response.json(result, { status: 201 });
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ return new Response(e.message, { status: 400 });
+ }
+ console.error("Error creating saved view:", e);
+ return new Response("Internal server error", { status: 500 });
+ }
+}
diff --git a/studio/app/embeds/[embedId]/page.tsx b/studio/app/embeds/[embedId]/page.tsx
index 729e2668..d0e43276 100644
--- a/studio/app/embeds/[embedId]/page.tsx
+++ b/studio/app/embeds/[embedId]/page.tsx
@@ -35,4 +35,4 @@ function EmbedPage({ params }: ProjectPageProps) {
);
}
-export default withPageAuthRequired(withUserEnabled(EmbedPage));
+export default EmbedPage;
diff --git a/studio/app/user-not-enabled/page.tsx b/studio/app/user-not-enabled/page.tsx
index 1683ad22..5af01d69 100644
--- a/studio/app/user-not-enabled/page.tsx
+++ b/studio/app/user-not-enabled/page.tsx
@@ -17,7 +17,7 @@ function UserNotEnabledPage() {
Your account has not been enabled. Please contact an administrator at{" "}
- vojta@stdio.cz
+ hi@stdio.cz
diff --git a/studio/core/defaults.ts b/studio/core/defaults.ts
new file mode 100644
index 00000000..99ce00d0
--- /dev/null
+++ b/studio/core/defaults.ts
@@ -0,0 +1,5 @@
+import { SpectrumToastOptions } from "@react-spectrum/toast";
+
+export const toasterOptions: SpectrumToastOptions = {
+ timeout: 5000,
+};
\ No newline at end of file
diff --git a/studio/core/icons/MdiBookmark.tsx b/studio/core/icons/MdiBookmark.tsx
new file mode 100644
index 00000000..aed49105
--- /dev/null
+++ b/studio/core/icons/MdiBookmark.tsx
@@ -0,0 +1,13 @@
+import { Icon as AdobeIcon } from "@adobe/react-spectrum";
+import { IconProps } from "./Icon";
+
+export function MdiBookmark(props: IconProps) {
+ const { transform, ...rest } = props;
+ return (
+
+
+
+ );
+}
diff --git a/studio/core/icons/MdiCamera.tsx b/studio/core/icons/MdiCamera.tsx
new file mode 100644
index 00000000..57275fce
--- /dev/null
+++ b/studio/core/icons/MdiCamera.tsx
@@ -0,0 +1,16 @@
+import { Icon as AdobeIcon } from "@adobe/react-spectrum";
+import { IconProps } from "./Icon";
+
+export function MdiCamera(props: IconProps) {
+ const { transform, ...rest } = props;
+ return (
+
+
+
+ );
+}
diff --git a/studio/features/api-sdk/uploadEmbed.ts b/studio/features/api-sdk/uploadEmbed.ts
index c7bcce05..3641f7de 100644
--- a/studio/features/api-sdk/uploadEmbed.ts
+++ b/studio/features/api-sdk/uploadEmbed.ts
@@ -5,6 +5,8 @@ export default async function uploadEmbed(
dataFile: File,
thumbnailFileContents: string,
name: string,
+ onlyTooltipInfo?: boolean,
+ savedViewIds?: number[],
) {
const formData = new FormData();
@@ -12,8 +14,33 @@ export default async function uploadEmbed(
formData.append("dataFile", dataFile);
formData.append("thumbnailFileContents", thumbnailFileContents);
formData.append("name", name);
+ if (onlyTooltipInfo) formData.append("onlyTooltipInfo", "on");
- const response = await axios.post("/api/embeds", formData);
+ // Add saved view IDs to form data
+ if (savedViewIds && savedViewIds.length > 0) {
+ savedViewIds.forEach((id) => {
+ formData.append("savedViewIds", id.toString());
+ });
+ }
- return response;
+ console.log("Uploading embed with data:", {
+ projectId,
+ name,
+ onlyTooltipInfo,
+ savedViewIds,
+ formDataKeys: Array.from(formData.keys()),
+ });
+
+ try {
+ const response = await axios.post("/api/embeds", formData);
+ console.log("Upload successful:", response.data);
+ return response;
+ } catch (error) {
+ console.error("Upload failed:", error);
+ if (axios.isAxiosError(error) && error.response) {
+ console.error("Response data:", error.response.data);
+ console.error("Response status:", error.response.status);
+ }
+ throw error;
+ }
}
diff --git a/studio/features/bananagl/camera/camera.ts b/studio/features/bananagl/camera/camera.ts
index 4880476f..9d5db9f6 100644
--- a/studio/features/bananagl/camera/camera.ts
+++ b/studio/features/bananagl/camera/camera.ts
@@ -1,516 +1,526 @@
-import { mat4, quat, vec2, vec3 } from 'gl-matrix';
+import { mat4, quat, vec2, vec3 } from "gl-matrix";
-import { Ray } from '@bananagl/picking/ray';
-import { UniformValue } from '@bananagl/shaders/shader';
+import { Ray } from "@bananagl/picking/ray";
+import { UniformValue } from "@bananagl/shaders/shader";
-import { CameraOptions, ProjectionType } from './cameraInterface';
+import { CameraOptions, ProjectionType } from "./cameraInterface";
export class Camera {
- position: vec3;
- target: vec3;
- type: ProjectionType;
- fovYRadian: number;
- aspectRatio: number;
- near: number;
- far: number;
-
- readonly projectionMatrix: mat4 = mat4.create();
- readonly viewProjectionInverse: mat4 = mat4.create();
- readonly viewMatrix: mat4 = mat4.create();
- readonly projectionViewMatrix: mat4 = mat4.create();
-
- private upV: vec3;
- private rightV: vec3;
- private directionTMP: vec3 = vec3.create();
- private upTMP: vec3 = vec3.create();
-
- private uniforms_: { [uniform: string]: UniformValue } = {};
-
- private width: number = 0;
- private height: number = 0;
- private screenSize: vec2 = vec2.create();
- private maxangle: number = Math.PI / 2;
- private minangle: number = 0;
-
- private left: number = 0;
- private right: number = 0;
- private bottom: number = 0;
- private top: number = 0;
-
- get uniforms() {
- return this.uniforms_;
- }
-
- constructor(options: CameraOptions = {}) {
- this.position = options.position ?? vec3.fromValues(0, 0, 5000);
- this.target = options.target ?? vec3.fromValues(0, 0, 0);
- this.upV = options.up ?? vec3.fromValues(0, 0, 1);
- this.rightV = options.right ?? vec3.fromValues(1, 0, 0);
- this.type = options.projectionType ?? ProjectionType.ORTHOGRAPHIC;
- this.fovYRadian = options.fovYRadian ?? Math.PI / 4;
- this.width = options.width ?? 1;
- this.height = options.height ?? 1;
- this.aspectRatio = this.width / this.height;
- this.near = options.near ?? 1;
- this.far = options.far ?? 10000;
-
- this.uniforms_['uCameraPosition'] = this.position;
- this.uniforms_['uCameraTarget'] = this.target;
- this.uniforms_['uCameraUp'] = this.upV;
- this.uniforms_['uProjectionMatrix'] = this.projectionMatrix;
- this.uniforms_['uViewMatrix'] = this.viewMatrix;
- this.uniforms_['uProjectionViewMatrix'] = this.projectionViewMatrix;
- this.uniforms_['uScreenSize'] = this.screenSize;
-
- this.updateOrthoBounds();
- this.updateMatrices();
- }
-
- private updateOrthoBounds() {
- this.left = this.width / -2;
- this.right = this.width / 2;
- this.bottom = this.height / -2;
- this.top = this.height / 2;
- }
-
- set(options: CameraOptions) {
- if (options.position) this.position = options.position;
- if (options.target) this.target = options.target;
- if (options.up) this.upV = options.up;
- if (options.right) this.rightV = options.right;
- if (options.projectionType) this.projectionType = options.projectionType;
- if (options.fovYRadian) this.fovYRadian = options.fovYRadian;
- if (options.width) this.width = options.width;
- if (options.height) this.height = options.height;
- this.aspectRatio = this.width / this.height;
- if (options.near) this.near = options.near;
- if (options.far) this.far = options.far;
- this.updateOrthoBounds();
- this.updateMatrices();
- }
-
- private getFrustrumWidthAtTarget(): number {
- if (this.projectionType === ProjectionType.ORTHOGRAPHIC) return this.right - this.left;
- const distance = vec3.dist(this.position, this.target);
- return 2 * distance * Math.tan(this.fovYRadian * 0.5);
- }
-
- private getFrustumHeightAtTarget(): number {
- if (this.projectionType === ProjectionType.ORTHOGRAPHIC) return this.top - this.bottom;
- return this.getFrustrumWidthAtTarget() / this.aspectRatio;
- }
-
- private swapPositionForProjection(newProjection: ProjectionType) {
- vec3.sub(this.directionTMP, this.target, this.position);
- vec3.normalize(this.directionTMP, this.directionTMP);
-
- if (newProjection === ProjectionType.PERSPECTIVE) {
- const width = this.getFrustrumWidthAtTarget();
- const distance = width / (2 * Math.tan(this.fovYRadian * 0.5));
- vec3.scale(this.directionTMP, this.directionTMP, distance);
- vec3.sub(this.position, this.target, this.directionTMP);
- } else {
- const width = this.getFrustrumWidthAtTarget();
- const height = width / this.aspectRatio;
- const initDistance = 5000;
- vec3.scale(this.directionTMP, this.directionTMP, initDistance);
- vec3.sub(this.position, this.target, this.directionTMP);
-
- this.left = -width / 2;
- this.right = width / 2;
- this.bottom = -height / 2;
- this.top = height / 2;
- }
- }
-
- //Returns normalized direction vector
- private get direction() {
- vec3.sub(this.directionTMP, this.target, this.position);
- vec3.normalize(this.directionTMP, this.directionTMP);
- return this.directionTMP;
- }
-
- //Returns normalized *approximate* up vector - the vector is either z-axis up or the cross product of the right and direction vector
- private get up() {
- const direction = this.direction;
- if (Math.abs(vec3.dot(direction, this.upV)) === 1) {
- return vec3.cross(this.upTMP, this.rightV, direction);
- }
- return this.upV;
- }
-
- //Returns *true* normalized up vector - the vector is the cross product of the right and direction vector
- private get trueUp() {
- return vec3.cross(this.upTMP, this.rightV, this.direction);
- }
-
- private updateViewMatrix() {
- mat4.lookAt(this.viewMatrix, this.position, this.target, this.up);
- }
-
- private updateProjectionMatrix() {
- if (this.projectionType === ProjectionType.PERSPECTIVE) {
- mat4.perspective(
- this.projectionMatrix,
- this.fovYRadian,
- this.aspectRatio,
- this.near,
- this.far
- );
- } else {
- const height = this.getFrustumHeightAtTarget();
- const width = height * this.aspectRatio;
- mat4.ortho(
- this.projectionMatrix,
- this.left,
- this.right,
- this.bottom,
- this.top,
- this.near,
- this.far
- );
- }
- }
-
- set projectionType(projectionType: ProjectionType) {
- if (projectionType === this.projectionType) return;
- this.swapPositionForProjection(projectionType);
- this.type = projectionType;
- this.updateMatrices();
- }
-
- get projectionType(): ProjectionType {
- return this.type;
- }
-
- private updateMatrices() {
- this.updateViewMatrix();
- this.updateProjectionMatrix();
- }
-
- updateProjectionViewMatrix() {
- this.updateMatrices();
- mat4.multiply(this.projectionViewMatrix, this.viewMatrix, this.projectionMatrix);
- mat4.invert(this.viewProjectionInverse, this.projectionViewMatrix);
- }
-
- updateAspectRatio(width: number, height: number) {
- this.aspectRatio = width / height;
- this.width = width;
- this.height = height;
- vec2.set(this.screenSize, width, height);
- this.updateOrthoBounds();
- this.updateProjectionViewMatrix();
- }
-
- private updateRightVector() {
- vec3.cross(this.rightV, this.direction, this.up);
- this.rightV[2] = 0;
- vec3.normalize(this.rightV, this.rightV);
- }
-
- get isOrthographic() {
- return this.projectionType === ProjectionType.ORTHOGRAPHIC;
- }
-
- private rightTMP = vec3.create();
- private frontTMP = vec3.create();
-
- pan(x: number, y: number) {
- const right = this.rightTMP;
- vec3.copy(right, this.rightV);
- const up = this.trueUp;
-
- //This is a bit risky, since the right does not always have to be in the horizontal plane
- const heightUnit = this.getFrustumHeightAtTarget() / this.height;
- const widthUnit = this.getFrustrumWidthAtTarget() / this.width;
-
- vec3.normalize(right, right);
- vec3.normalize(up, up);
-
- vec3.scale(right, right, -widthUnit * x);
- vec3.scale(up, up, heightUnit * y);
-
- vec3.add(this.position, this.position, right);
- vec3.add(this.position, this.position, up);
- vec3.add(this.target, this.target, right);
- vec3.add(this.target, this.target, up);
-
- this.updateRightVector();
- this.updateProjectionViewMatrix();
- }
-
- rotate(x: number, y: number) {
- const angleX = (x / this.width) * 2 * Math.PI;
- let angleY = -(y / this.height) * Math.PI;
-
- //rotate using quaternion around target
- const q1 = quat.create();
- const q2 = quat.create();
-
- const direction = this.direction;
- vec3.negate(direction, direction);
- const currentYAngle = Math.acos(vec3.dot(direction, this.upV));
-
- if (currentYAngle + angleY > this.maxangle) {
- angleY = this.maxangle - currentYAngle;
- } else if (currentYAngle + angleY < this.minangle) {
- angleY = this.minangle - currentYAngle;
- }
-
- quat.setAxisAngle(q1, this.rightV, angleY);
- quat.setAxisAngle(q2, this.upV, -angleX);
- quat.multiply(q1, q1, q2);
-
- vec3.sub(this.position, this.position, this.target);
- vec3.transformQuat(this.position, this.position, q1);
- vec3.transformQuat(this.rightV, this.rightV, q1);
- vec3.add(this.position, this.position, this.target);
- this.updateRightVector();
-
- this.updateProjectionViewMatrix();
- }
-
- zoom(delta: number, cursorPxX: number, cursorPxY: number) {
- const factor = delta > 0 ? 1.1 : 0.9;
- if (this.isOrthographic) {
- this.zoomOrthographic(factor, cursorPxX, cursorPxY);
- } else {
- this.zoomPerspective(factor, cursorPxX, cursorPxY);
- }
- }
-
- displacement = vec3.create();
- aPosTMP = vec3.create();
- bPosTMP = vec3.create();
-
- private zoomOrthographic(factor: number, cursorPerctX: number, cursorPerctY: number) {
- const dir = this.direction;
- const right = this.rightV;
- const pos = this.position;
- const up = this.trueUp;
- cursorPerctY = 1 - cursorPerctY; //Flip y axis since webgl has origin in bottom left corner
-
- //Convert cursor position from screen space to world space at the near plane
- const width = this.right - this.left;
- const height = this.top - this.bottom;
-
- const cursorWorldOffsetX = width * cursorPerctX - width * 0.5; // Offset from center of view.
- const cursorWorldOffsetY = height * cursorPerctY - height * 0.5; // Offset from center of view.
-
- const nearPos = vec3.set(
- this.aPosTMP,
- pos[0] + cursorWorldOffsetX * right[0] + cursorWorldOffsetY * up[0],
- pos[1] + cursorWorldOffsetX * right[1] + cursorWorldOffsetY * up[1],
- pos[2] + cursorWorldOffsetX * right[2] + cursorWorldOffsetY * up[2]
- );
-
- vec3.scaleAndAdd(nearPos, nearPos, dir, this.near);
-
- //Zoom the camera
- this.left = this.left * factor;
- this.right = this.right * factor;
- this.top = this.top * factor;
- this.bottom = this.bottom * factor;
-
- //Find the world space position of the cursor after zoom at the near plane
- const newWidth = this.right - this.left;
- const newHeight = this.top - this.bottom;
-
- const newCursorWorldOffsetX = newWidth * cursorPerctX - newWidth * 0.5;
- const newCursorWorldOffsetY = newHeight * cursorPerctY - newHeight * 0.5;
-
- const newNearPos = vec3.set(
- this.bPosTMP,
- pos[0] + newCursorWorldOffsetX * right[0] + newCursorWorldOffsetY * up[0],
- pos[1] + newCursorWorldOffsetX * right[1] + newCursorWorldOffsetY * up[1],
- pos[2] + newCursorWorldOffsetX * right[2] + newCursorWorldOffsetY * up[2]
- );
-
- vec3.scaleAndAdd(newNearPos, newNearPos, dir, this.near);
-
- //Calculate the displacement and adjust the camera's position
- const displacement = vec3.sub(this.displacement, newNearPos, nearPos);
- vec3.sub(this.position, this.position, displacement);
- vec3.sub(this.target, this.target, displacement);
-
- this.updateProjectionViewMatrix();
- }
-
- private zoomPerspective(factor: number, cursorPerctX: number, cursorPerctY: number) {
- const offset = this.direction;
- const right = this.rightV;
- const pos = this.position;
- const tar = this.target;
- const up = this.trueUp;
- cursorPerctY = 1 - cursorPerctY; //Flip y axis since webgl has origin in bottom left corner
-
- //Get the world space position of the cursor on the target plane
- let targetPlaneW = this.getFrustrumWidthAtTarget();
- let targetPlaneH = targetPlaneW / this.aspectRatio;
- let cursorTargetPlaneOffsetX = targetPlaneW * cursorPerctX - targetPlaneW * 0.5;
- let cursorTargetPlaneOffsetY = targetPlaneH * cursorPerctY - targetPlaneH * 0.5;
-
- const tarPos = vec3.set(
- this.aPosTMP,
- tar[0] + cursorTargetPlaneOffsetX * right[0] + cursorTargetPlaneOffsetY * up[0],
- tar[1] + cursorTargetPlaneOffsetX * right[1] + cursorTargetPlaneOffsetY * up[1],
- tar[2] + cursorTargetPlaneOffsetX * right[2] + cursorTargetPlaneOffsetY * up[2]
- );
-
- //Zoom
- vec3.sub(offset, pos, this.target);
- vec3.scale(offset, offset, factor);
- vec3.add(pos, this.target, offset);
-
- //Get the world space position of the cursor on the near plane after the zoom
- targetPlaneW = this.getFrustrumWidthAtTarget();
- targetPlaneH = targetPlaneW / this.aspectRatio;
- cursorTargetPlaneOffsetX = targetPlaneW * cursorPerctX - targetPlaneW * 0.5;
- cursorTargetPlaneOffsetY = targetPlaneH * cursorPerctY - targetPlaneH * 0.5;
-
- const newTarPos = vec3.set(
- this.bPosTMP,
- tar[0] + cursorTargetPlaneOffsetX * right[0] + cursorTargetPlaneOffsetY * up[0],
- tar[1] + cursorTargetPlaneOffsetX * right[1] + cursorTargetPlaneOffsetY * up[1],
- tar[2] + cursorTargetPlaneOffsetX * right[2] + cursorTargetPlaneOffsetY * up[2]
- );
-
- //Calculate the displacement and adjust the camera's position
- const displacement = vec3.sub(this.displacement, newTarPos, tarPos);
- vec3.sub(this.position, this.position, displacement);
- vec3.sub(this.target, this.target, displacement);
- this.updateProjectionViewMatrix();
- }
-
- topView() {
- const distance = vec3.distance(this.position, this.target);
- vec3.set(this.position, this.target[0], this.target[1], this.target[2] + distance);
- vec3.set(this.rightV, 1, 0, 0);
- this.updateProjectionViewMatrix();
- }
-
- frontView() {
- const distance = vec3.distance(this.position, this.target);
- vec3.set(this.position, this.target[0], this.target[1] - distance, this.target[2]);
- this.updateRightVector();
- this.updateProjectionViewMatrix();
- }
-
- rightView() {
- const distance = vec3.distance(this.position, this.target);
- vec3.set(this.position, this.target[0] + distance, this.target[1], this.target[2]);
- this.updateRightVector();
- this.updateProjectionViewMatrix();
- }
-
- leftView() {
- const distance = vec3.distance(this.position, this.target);
- vec3.set(this.position, this.target[0] - distance, this.target[1], this.target[2]);
- this.updateRightVector();
- this.updateProjectionViewMatrix();
- }
-
- backView() {
- const distance = vec3.distance(this.position, this.target);
- vec3.set(this.position, this.target[0], this.target[1] + distance, this.target[2]);
- this.updateRightVector();
- this.updateProjectionViewMatrix();
- }
-
- get z() {
- return this.target[2];
- }
-
- set z(z: number) {
- const delta = z - this.target[2];
- this.target[2] += delta;
- this.position[2] += delta;
- this.updateProjectionViewMatrix();
- }
-
- primaryRay(ndcX: number, ndcY: number) {
- if (this.isOrthographic) {
- return this.primaryRayOrthographic(ndcX, ndcY);
- }
- return this.primaryRayPerspective(ndcX, ndcY);
- }
-
- private primaryRayOrthographic(ndcX: number, ndcY: number) {
- const origin = vec3.create();
- // Normalize camera direction
- const direction = this.direction;
- const frustumWidth = this.right - this.left;
- const frustumHeight = this.top - this.bottom;
-
- // Compute the right and up vectors for the camera
- const right = this.rightV;
- const up = vec3.create();
- vec3.cross(up, right, direction);
- vec3.normalize(up, up);
-
- // Compute the world space coordinates of the pixel
- const unitWorldX = ndcX * frustumWidth * 0.5;
- const unitWorldY = ndcY * frustumHeight * 0.5;
-
- // Compute the primary ray's origin
- vec3.scaleAndAdd(origin, this.position, right, unitWorldX);
- vec3.scaleAndAdd(origin, origin, up, unitWorldY);
-
- const ray = new Ray();
- vec3.copy(ray.origin, origin);
- vec3.copy(ray.direction, direction);
-
- return ray;
- }
-
- private primaryRayPerspective(ndcX: number, ndcY: number) {
- // Normalize camera direction
- const direction = vec3.create();
- vec3.copy(direction, this.direction);
-
- // Compute the right and up vectors for the camera
- const up = vec3.create();
- const right = this.rightV;
- vec3.cross(up, right, direction);
- vec3.normalize(up, up);
-
- // Compute the half width and half height of the near plane
- const halfHeight = Math.tan(this.fovYRadian / 2);
- const halfWidth = halfHeight * this.aspectRatio;
-
- // Compute the world space coordinates of the pixel
- const unitWorldX = ndcX * halfWidth;
- const unitWorldY = ndcY * halfHeight;
-
- // Compute the primary ray's direction
- vec3.scaleAndAdd(direction, direction, right, unitWorldX);
- vec3.scaleAndAdd(direction, direction, up, unitWorldY);
- vec3.normalize(direction, direction);
-
- const ray = new Ray();
- vec3.copy(ray.origin, this.position);
- vec3.copy(ray.direction, direction);
- return ray;
- }
-
- cameraPlaneVector(dx: number, dy: number) {
- const currentUp = vec3.create();
- vec3.cross(currentUp, this.rightV, this.direction);
- vec3.normalize(currentUp, currentUp);
-
- const currentRight = vec3.create();
- vec3.cross(currentRight, this.direction, currentUp);
- vec3.normalize(currentRight, currentRight);
-
- const heightUnit = this.getFrustumHeightAtTarget() / this.height;
- const widthUnit = this.getFrustrumWidthAtTarget() / this.width;
-
- vec3.scale(currentRight, currentRight, widthUnit * dx);
- vec3.scale(currentUp, currentUp, -heightUnit * dy);
-
- const dir = vec3.create();
- vec3.add(dir, currentRight, currentUp);
- return dir;
- }
+ position: vec3;
+ target: vec3;
+ type: ProjectionType;
+ fovYRadian: number;
+ aspectRatio: number;
+ near: number;
+ far: number;
+
+ readonly projectionMatrix: mat4 = mat4.create();
+ readonly viewProjectionInverse: mat4 = mat4.create();
+ readonly viewMatrix: mat4 = mat4.create();
+ readonly projectionViewMatrix: mat4 = mat4.create();
+
+ private upV: vec3;
+ private rightV: vec3;
+ private directionTMP: vec3 = vec3.create();
+ private upTMP: vec3 = vec3.create();
+
+ private uniforms_: { [uniform: string]: UniformValue } = {};
+
+ private width: number = 0;
+ private height: number = 0;
+ private screenSize: vec2 = vec2.create();
+ private maxangle: number = Math.PI / 2;
+ private minangle: number = 0;
+
+ private left: number = 0;
+ private right: number = 0;
+ private bottom: number = 0;
+ private top: number = 0;
+
+ get uniforms() {
+ return this.uniforms_;
+ }
+
+ constructor(options: CameraOptions = {}) {
+ this.position = options.position ?? vec3.fromValues(0, 0, 5000);
+ this.target = options.target ?? vec3.fromValues(0, 0, 0);
+ this.upV = options.up ?? vec3.fromValues(0, 0, 1);
+ this.rightV = options.right ?? vec3.fromValues(1, 0, 0);
+ this.type = options.projectionType ?? ProjectionType.ORTHOGRAPHIC;
+ this.fovYRadian = options.fovYRadian ?? Math.PI / 4;
+ this.width = options.width ?? 1;
+ this.height = options.height ?? 1;
+ this.aspectRatio = this.width / this.height;
+ this.near = options.near ?? 1;
+ this.far = options.far ?? 10000;
+
+ this.uniforms_["uCameraPosition"] = this.position;
+ this.uniforms_["uCameraTarget"] = this.target;
+ this.uniforms_["uCameraUp"] = this.upV;
+ this.uniforms_["uProjectionMatrix"] = this.projectionMatrix;
+ this.uniforms_["uViewMatrix"] = this.viewMatrix;
+ this.uniforms_["uProjectionViewMatrix"] = this.projectionViewMatrix;
+ this.uniforms_["uScreenSize"] = this.screenSize;
+
+ this.updateOrthoBounds();
+ this.updateMatrices();
+ }
+
+ private updateOrthoBounds() {
+ this.left = this.width / -2;
+ this.right = this.width / 2;
+ this.bottom = this.height / -2;
+ this.top = this.height / 2;
+ }
+
+ set(options: CameraOptions) {
+ if (options.position) this.position = options.position;
+ if (options.target) this.target = options.target;
+ if (options.up) this.upV = options.up;
+ if (options.right) this.rightV = options.right;
+ if (options.projectionType) this.projectionType = options.projectionType;
+ if (options.fovYRadian) this.fovYRadian = options.fovYRadian;
+ if (options.width) this.width = options.width;
+ if (options.height) this.height = options.height;
+ this.aspectRatio = this.width / this.height;
+ if (options.near) this.near = options.near;
+ if (options.far) this.far = options.far;
+ this.updateOrthoBounds();
+ this.updateMatrices();
+ }
+
+ private getFrustrumWidthAtTarget(): number {
+ if (this.projectionType === ProjectionType.ORTHOGRAPHIC) return this.right - this.left;
+ const distance = vec3.dist(this.position, this.target);
+ return 2 * distance * Math.tan(this.fovYRadian * 0.5);
+ }
+
+ private getFrustumHeightAtTarget(): number {
+ if (this.projectionType === ProjectionType.ORTHOGRAPHIC) return this.top - this.bottom;
+ return this.getFrustrumWidthAtTarget() / this.aspectRatio;
+ }
+
+ private swapPositionForProjection(newProjection: ProjectionType) {
+ vec3.sub(this.directionTMP, this.target, this.position);
+ vec3.normalize(this.directionTMP, this.directionTMP);
+
+ if (newProjection === ProjectionType.PERSPECTIVE) {
+ const width = this.getFrustrumWidthAtTarget();
+ const distance = width / (2 * Math.tan(this.fovYRadian * 0.5));
+ vec3.scale(this.directionTMP, this.directionTMP, distance);
+ vec3.sub(this.position, this.target, this.directionTMP);
+ } else {
+ const width = this.getFrustrumWidthAtTarget();
+ const height = width / this.aspectRatio;
+ const initDistance = 5000;
+ vec3.scale(this.directionTMP, this.directionTMP, initDistance);
+ vec3.sub(this.position, this.target, this.directionTMP);
+
+ this.left = -width / 2;
+ this.right = width / 2;
+ this.bottom = -height / 2;
+ this.top = height / 2;
+ }
+ }
+
+ //Returns normalized direction vector
+ private get direction() {
+ vec3.sub(this.directionTMP, this.target, this.position);
+ vec3.normalize(this.directionTMP, this.directionTMP);
+ return this.directionTMP;
+ }
+
+ //Returns normalized *approximate* up vector - the vector is either z-axis up or the cross product of the right and direction vector
+ private get up() {
+ const direction = this.direction;
+ if (Math.abs(vec3.dot(direction, this.upV)) === 1) {
+ return vec3.cross(this.upTMP, this.rightV, direction);
+ }
+ return this.upV;
+ }
+
+ //Returns *true* normalized up vector - the vector is the cross product of the right and direction vector
+ private get trueUp() {
+ return vec3.cross(this.upTMP, this.rightV, this.direction);
+ }
+
+ private updateViewMatrix() {
+ mat4.lookAt(this.viewMatrix, this.position, this.target, this.up);
+ }
+
+ private updateProjectionMatrix() {
+ if (this.projectionType === ProjectionType.PERSPECTIVE) {
+ mat4.perspective(this.projectionMatrix, this.fovYRadian, this.aspectRatio, this.near, this.far);
+ } else {
+ const height = this.getFrustumHeightAtTarget();
+ const width = height * this.aspectRatio;
+ mat4.ortho(this.projectionMatrix, this.left, this.right, this.bottom, this.top, this.near, this.far);
+ }
+ }
+
+ set projectionType(projectionType: ProjectionType) {
+ if (projectionType === this.projectionType) return;
+ this.swapPositionForProjection(projectionType);
+ this.type = projectionType;
+ this.updateMatrices();
+ }
+
+ get projectionType(): ProjectionType {
+ return this.type;
+ }
+
+ get orthographicLeft(): number {
+ return this.left;
+ }
+
+ get orthographicRight(): number {
+ return this.right;
+ }
+
+ get orthographicBottom(): number {
+ return this.bottom;
+ }
+
+ get orthographicTop(): number {
+ return this.top;
+ }
+
+ setOrthographicBounds(left: number, right: number, bottom: number, top: number) {
+ this.left = left;
+ this.right = right;
+ this.bottom = bottom;
+ this.top = top;
+ this.updateMatrices();
+ }
+
+ private updateMatrices() {
+ this.updateViewMatrix();
+ this.updateProjectionMatrix();
+ }
+
+ updateProjectionViewMatrix() {
+ this.updateMatrices();
+ mat4.multiply(this.projectionViewMatrix, this.viewMatrix, this.projectionMatrix);
+ mat4.invert(this.viewProjectionInverse, this.projectionViewMatrix);
+ }
+
+ updateAspectRatio(width: number, height: number) {
+ this.aspectRatio = width / height;
+ this.width = width;
+ this.height = height;
+ vec2.set(this.screenSize, width, height);
+ this.updateOrthoBounds();
+ this.updateProjectionViewMatrix();
+ }
+
+ private updateRightVector() {
+ vec3.cross(this.rightV, this.direction, this.up);
+ this.rightV[2] = 0;
+ vec3.normalize(this.rightV, this.rightV);
+ }
+
+ get isOrthographic() {
+ return this.projectionType === ProjectionType.ORTHOGRAPHIC;
+ }
+
+ private rightTMP = vec3.create();
+ private frontTMP = vec3.create();
+
+ pan(x: number, y: number) {
+ const right = this.rightTMP;
+ vec3.copy(right, this.rightV);
+ const up = this.trueUp;
+
+ //This is a bit risky, since the right does not always have to be in the horizontal plane
+ const heightUnit = this.getFrustumHeightAtTarget() / this.height;
+ const widthUnit = this.getFrustrumWidthAtTarget() / this.width;
+
+ vec3.normalize(right, right);
+ vec3.normalize(up, up);
+
+ vec3.scale(right, right, -widthUnit * x);
+ vec3.scale(up, up, heightUnit * y);
+
+ vec3.add(this.position, this.position, right);
+ vec3.add(this.position, this.position, up);
+ vec3.add(this.target, this.target, right);
+ vec3.add(this.target, this.target, up);
+
+ this.updateRightVector();
+ this.updateProjectionViewMatrix();
+ }
+
+ rotate(x: number, y: number) {
+ const angleX = (x / this.width) * 2 * Math.PI;
+ let angleY = -(y / this.height) * Math.PI;
+
+ //rotate using quaternion around target
+ const q1 = quat.create();
+ const q2 = quat.create();
+
+ const direction = this.direction;
+ vec3.negate(direction, direction);
+ const currentYAngle = Math.acos(vec3.dot(direction, this.upV));
+
+ if (currentYAngle + angleY > this.maxangle) {
+ angleY = this.maxangle - currentYAngle;
+ } else if (currentYAngle + angleY < this.minangle) {
+ angleY = this.minangle - currentYAngle;
+ }
+
+ quat.setAxisAngle(q1, this.rightV, angleY);
+ quat.setAxisAngle(q2, this.upV, -angleX);
+ quat.multiply(q1, q1, q2);
+
+ vec3.sub(this.position, this.position, this.target);
+ vec3.transformQuat(this.position, this.position, q1);
+ vec3.transformQuat(this.rightV, this.rightV, q1);
+ vec3.add(this.position, this.position, this.target);
+ this.updateRightVector();
+
+ this.updateProjectionViewMatrix();
+ }
+
+ zoom(delta: number, cursorPxX: number, cursorPxY: number) {
+ const factor = delta > 0 ? 1.1 : 0.9;
+ if (this.isOrthographic) {
+ this.zoomOrthographic(factor, cursorPxX, cursorPxY);
+ } else {
+ this.zoomPerspective(factor, cursorPxX, cursorPxY);
+ }
+ }
+
+ displacement = vec3.create();
+ aPosTMP = vec3.create();
+ bPosTMP = vec3.create();
+
+ private zoomOrthographic(factor: number, cursorPerctX: number, cursorPerctY: number) {
+ const dir = this.direction;
+ const right = this.rightV;
+ const pos = this.position;
+ const up = this.trueUp;
+ cursorPerctY = 1 - cursorPerctY; //Flip y axis since webgl has origin in bottom left corner
+
+ //Convert cursor position from screen space to world space at the near plane
+ const width = this.right - this.left;
+ const height = this.top - this.bottom;
+
+ const cursorWorldOffsetX = width * cursorPerctX - width * 0.5; // Offset from center of view.
+ const cursorWorldOffsetY = height * cursorPerctY - height * 0.5; // Offset from center of view.
+
+ const nearPos = vec3.set(
+ this.aPosTMP,
+ pos[0] + cursorWorldOffsetX * right[0] + cursorWorldOffsetY * up[0],
+ pos[1] + cursorWorldOffsetX * right[1] + cursorWorldOffsetY * up[1],
+ pos[2] + cursorWorldOffsetX * right[2] + cursorWorldOffsetY * up[2],
+ );
+
+ vec3.scaleAndAdd(nearPos, nearPos, dir, this.near);
+
+ //Zoom the camera
+ this.left = this.left * factor;
+ this.right = this.right * factor;
+ this.top = this.top * factor;
+ this.bottom = this.bottom * factor;
+
+ //Find the world space position of the cursor after zoom at the near plane
+ const newWidth = this.right - this.left;
+ const newHeight = this.top - this.bottom;
+
+ const newCursorWorldOffsetX = newWidth * cursorPerctX - newWidth * 0.5;
+ const newCursorWorldOffsetY = newHeight * cursorPerctY - newHeight * 0.5;
+
+ const newNearPos = vec3.set(
+ this.bPosTMP,
+ pos[0] + newCursorWorldOffsetX * right[0] + newCursorWorldOffsetY * up[0],
+ pos[1] + newCursorWorldOffsetX * right[1] + newCursorWorldOffsetY * up[1],
+ pos[2] + newCursorWorldOffsetX * right[2] + newCursorWorldOffsetY * up[2],
+ );
+
+ vec3.scaleAndAdd(newNearPos, newNearPos, dir, this.near);
+
+ //Calculate the displacement and adjust the camera's position
+ const displacement = vec3.sub(this.displacement, newNearPos, nearPos);
+ vec3.sub(this.position, this.position, displacement);
+ vec3.sub(this.target, this.target, displacement);
+
+ this.updateProjectionViewMatrix();
+ }
+
+ private zoomPerspective(factor: number, cursorPerctX: number, cursorPerctY: number) {
+ const offset = this.direction;
+ const right = this.rightV;
+ const pos = this.position;
+ const tar = this.target;
+ const up = this.trueUp;
+ cursorPerctY = 1 - cursorPerctY; //Flip y axis since webgl has origin in bottom left corner
+
+ //Get the world space position of the cursor on the target plane
+ let targetPlaneW = this.getFrustrumWidthAtTarget();
+ let targetPlaneH = targetPlaneW / this.aspectRatio;
+ let cursorTargetPlaneOffsetX = targetPlaneW * cursorPerctX - targetPlaneW * 0.5;
+ let cursorTargetPlaneOffsetY = targetPlaneH * cursorPerctY - targetPlaneH * 0.5;
+
+ const tarPos = vec3.set(
+ this.aPosTMP,
+ tar[0] + cursorTargetPlaneOffsetX * right[0] + cursorTargetPlaneOffsetY * up[0],
+ tar[1] + cursorTargetPlaneOffsetX * right[1] + cursorTargetPlaneOffsetY * up[1],
+ tar[2] + cursorTargetPlaneOffsetX * right[2] + cursorTargetPlaneOffsetY * up[2],
+ );
+
+ //Zoom
+ vec3.sub(offset, pos, this.target);
+ vec3.scale(offset, offset, factor);
+ vec3.add(pos, this.target, offset);
+
+ //Get the world space position of the cursor on the near plane after the zoom
+ targetPlaneW = this.getFrustrumWidthAtTarget();
+ targetPlaneH = targetPlaneW / this.aspectRatio;
+ cursorTargetPlaneOffsetX = targetPlaneW * cursorPerctX - targetPlaneW * 0.5;
+ cursorTargetPlaneOffsetY = targetPlaneH * cursorPerctY - targetPlaneH * 0.5;
+
+ const newTarPos = vec3.set(
+ this.bPosTMP,
+ tar[0] + cursorTargetPlaneOffsetX * right[0] + cursorTargetPlaneOffsetY * up[0],
+ tar[1] + cursorTargetPlaneOffsetX * right[1] + cursorTargetPlaneOffsetY * up[1],
+ tar[2] + cursorTargetPlaneOffsetX * right[2] + cursorTargetPlaneOffsetY * up[2],
+ );
+
+ //Calculate the displacement and adjust the camera's position
+ const displacement = vec3.sub(this.displacement, newTarPos, tarPos);
+ vec3.sub(this.position, this.position, displacement);
+ vec3.sub(this.target, this.target, displacement);
+ this.updateProjectionViewMatrix();
+ }
+
+ topView() {
+ const distance = vec3.distance(this.position, this.target);
+ vec3.set(this.position, this.target[0], this.target[1], this.target[2] + distance);
+ vec3.set(this.rightV, 1, 0, 0);
+ this.updateProjectionViewMatrix();
+ }
+
+ frontView() {
+ const distance = vec3.distance(this.position, this.target);
+ vec3.set(this.position, this.target[0], this.target[1] - distance, this.target[2]);
+ this.updateRightVector();
+ this.updateProjectionViewMatrix();
+ }
+
+ rightView() {
+ const distance = vec3.distance(this.position, this.target);
+ vec3.set(this.position, this.target[0] + distance, this.target[1], this.target[2]);
+ this.updateRightVector();
+ this.updateProjectionViewMatrix();
+ }
+
+ leftView() {
+ const distance = vec3.distance(this.position, this.target);
+ vec3.set(this.position, this.target[0] - distance, this.target[1], this.target[2]);
+ this.updateRightVector();
+ this.updateProjectionViewMatrix();
+ }
+
+ backView() {
+ const distance = vec3.distance(this.position, this.target);
+ vec3.set(this.position, this.target[0], this.target[1] + distance, this.target[2]);
+ this.updateRightVector();
+ this.updateProjectionViewMatrix();
+ }
+
+ get z() {
+ return this.target[2];
+ }
+
+ set z(z: number) {
+ const delta = z - this.target[2];
+ this.target[2] += delta;
+ this.position[2] += delta;
+ this.updateProjectionViewMatrix();
+ }
+
+ primaryRay(ndcX: number, ndcY: number) {
+ if (this.isOrthographic) {
+ return this.primaryRayOrthographic(ndcX, ndcY);
+ }
+ return this.primaryRayPerspective(ndcX, ndcY);
+ }
+
+ private primaryRayOrthographic(ndcX: number, ndcY: number) {
+ const origin = vec3.create();
+ // Normalize camera direction
+ const direction = this.direction;
+ const frustumWidth = this.right - this.left;
+ const frustumHeight = this.top - this.bottom;
+
+ // Compute the right and up vectors for the camera
+ const right = this.rightV;
+ const up = vec3.create();
+ vec3.cross(up, right, direction);
+ vec3.normalize(up, up);
+
+ // Compute the world space coordinates of the pixel
+ const unitWorldX = ndcX * frustumWidth * 0.5;
+ const unitWorldY = ndcY * frustumHeight * 0.5;
+
+ // Compute the primary ray's origin
+ vec3.scaleAndAdd(origin, this.position, right, unitWorldX);
+ vec3.scaleAndAdd(origin, origin, up, unitWorldY);
+
+ const ray = new Ray();
+ vec3.copy(ray.origin, origin);
+ vec3.copy(ray.direction, direction);
+
+ return ray;
+ }
+
+ private primaryRayPerspective(ndcX: number, ndcY: number) {
+ // Normalize camera direction
+ const direction = vec3.create();
+ vec3.copy(direction, this.direction);
+
+ // Compute the right and up vectors for the camera
+ const up = vec3.create();
+ const right = this.rightV;
+ vec3.cross(up, right, direction);
+ vec3.normalize(up, up);
+
+ // Compute the half width and half height of the near plane
+ const halfHeight = Math.tan(this.fovYRadian / 2);
+ const halfWidth = halfHeight * this.aspectRatio;
+
+ // Compute the world space coordinates of the pixel
+ const unitWorldX = ndcX * halfWidth;
+ const unitWorldY = ndcY * halfHeight;
+
+ // Compute the primary ray's direction
+ vec3.scaleAndAdd(direction, direction, right, unitWorldX);
+ vec3.scaleAndAdd(direction, direction, up, unitWorldY);
+ vec3.normalize(direction, direction);
+
+ const ray = new Ray();
+ vec3.copy(ray.origin, this.position);
+ vec3.copy(ray.direction, direction);
+ return ray;
+ }
+
+ cameraPlaneVector(dx: number, dy: number) {
+ const currentUp = vec3.create();
+ vec3.cross(currentUp, this.rightV, this.direction);
+ vec3.normalize(currentUp, currentUp);
+
+ const currentRight = vec3.create();
+ vec3.cross(currentRight, this.direction, currentUp);
+ vec3.normalize(currentRight, currentRight);
+
+ const heightUnit = this.getFrustumHeightAtTarget() / this.height;
+ const widthUnit = this.getFrustrumWidthAtTarget() / this.width;
+
+ vec3.scale(currentRight, currentRight, widthUnit * dx);
+ vec3.scale(currentUp, currentUp, -heightUnit * dy);
+
+ const dir = vec3.create();
+ vec3.add(dir, currentRight, currentUp);
+ return dir;
+ }
}
diff --git a/studio/features/db/data-source.ts b/studio/features/db/data-source.ts
index 7d7bf7d1..564d7528 100644
--- a/studio/features/db/data-source.ts
+++ b/studio/features/db/data-source.ts
@@ -6,6 +6,7 @@ import { Embed } from "./entities/embed";
import { Model } from "./entities/model";
import { Project } from "./entities/project";
import { ProjectVersion } from "./entities/projectVersion";
+import { SavedView } from "./entities/savedView";
import { User } from "./entities/user";
export const AppDataSource = new DataSource({
@@ -15,7 +16,7 @@ export const AppDataSource = new DataSource({
username: Config.db.username,
password: Config.db.password,
database: Config.db.database,
- entities: [Project, User, Model, Embed, ProjectVersion],
+ entities: [Project, User, Model, Embed, ProjectVersion, SavedView],
namingStrategy: new SnakeNamingStrategy(),
logging: ["error"],
subscribers: [],
diff --git a/studio/features/db/entities/embed.ts b/studio/features/db/entities/embed.ts
index d367094a..67c6c75d 100644
--- a/studio/features/db/entities/embed.ts
+++ b/studio/features/db/entities/embed.ts
@@ -2,11 +2,14 @@ import {
Column,
CreateDateColumn,
Entity,
+ JoinTable,
+ ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
import { Project } from "./project";
+import { SavedView } from "./savedView";
@Entity("embeds")
export class Embed {
@@ -21,6 +24,22 @@ export class Embed {
@Column() thumbnailContents!: string;
+ @Column({ default: false }) onlyTooltipInfo!: boolean;
+
+ @ManyToMany(() => SavedView)
+ @JoinTable({
+ name: "embeds_saved_views_saved_views",
+ joinColumn: {
+ name: "embedsId",
+ referencedColumnName: "id",
+ },
+ inverseJoinColumn: {
+ name: "savedViewsId",
+ referencedColumnName: "id",
+ },
+ })
+ savedViews?: SavedView[];
+
@CreateDateColumn() createdAt!: Date;
@UpdateDateColumn() updatedAt!: Date;
}
diff --git a/studio/features/db/entities/savedView.ts b/studio/features/db/entities/savedView.ts
new file mode 100644
index 00000000..efde8d8d
--- /dev/null
+++ b/studio/features/db/entities/savedView.ts
@@ -0,0 +1,33 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
+import { Project } from "./project";
+
+@Entity("saved_views")
+export class SavedView {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column() name!: string;
+
+ @Column("float", { array: true }) cameraPosition!: [number, number, number];
+ @Column("float", { array: true }) cameraTarget!: [number, number, number];
+
+ @Column({ type: "enum", enum: ProjectionType, default: ProjectionType.ORTHOGRAPHIC })
+ projectionType!: ProjectionType;
+
+ @Column("float", { default: Math.PI / 4 }) fovYRadian!: number;
+
+ @Column("float", { default: -1 }) orthographicLeft!: number;
+ @Column("float", { default: 1 }) orthographicRight!: number;
+ @Column("float", { default: -1 }) orthographicBottom!: number;
+ @Column("float", { default: 1 }) orthographicTop!: number;
+
+ @ManyToOne(() => Project, {
+ onDelete: "CASCADE",
+ onUpdate: "CASCADE",
+ })
+ project?: Project;
+
+ @CreateDateColumn() created_at!: Date;
+ @UpdateDateColumn() updated_at!: Date;
+}
diff --git a/studio/features/db/migrations/1726147154131-add-only-tooltip-info.ts b/studio/features/db/migrations/1726147154131-add-only-tooltip-info.ts
new file mode 100644
index 00000000..77014e48
--- /dev/null
+++ b/studio/features/db/migrations/1726147154131-add-only-tooltip-info.ts
@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddOnlyTooltipInfo1726147154131 implements MigrationInterface {
+ name = "AddOnlyTooltipInfo1726147154131";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "embeds" ADD "only_tooltip_info" boolean NOT NULL DEFAULT false`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "embeds" DROP COLUMN "only_tooltip_info"`);
+ }
+}
diff --git a/studio/features/db/migrations/1750715800000-add-saved-views.ts b/studio/features/db/migrations/1750715800000-add-saved-views.ts
new file mode 100644
index 00000000..0358a3b4
--- /dev/null
+++ b/studio/features/db/migrations/1750715800000-add-saved-views.ts
@@ -0,0 +1,19 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddSavedViews1750715800000 implements MigrationInterface {
+ name = "AddSavedViews1750715800000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "saved_views" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "camera_position" float array NOT NULL, "camera_target" float array NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" integer, CONSTRAINT "PK_saved_views_id" PRIMARY KEY ("id"))`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "saved_views" ADD CONSTRAINT "FK_saved_views_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP CONSTRAINT "FK_saved_views_project"`);
+ await queryRunner.query(`DROP TABLE "saved_views"`);
+ }
+}
diff --git a/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts b/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts
new file mode 100644
index 00000000..1ef9fdb0
--- /dev/null
+++ b/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts
@@ -0,0 +1,35 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddEmbedSavedViewsRelationship1750715800001 implements MigrationInterface {
+ name = "AddEmbedSavedViewsRelationship1750715800001";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `CREATE TABLE "embeds_saved_views_saved_views" ("embedsId" integer NOT NULL, "savedViewsId" integer NOT NULL, CONSTRAINT "PK_embeds_saved_views_relationship" PRIMARY KEY ("embedsId", "savedViewsId"))`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_embeds_saved_views_embeds" ON "embeds_saved_views_saved_views" ("embedsId")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_embeds_saved_views_saved_views" ON "embeds_saved_views_saved_views" ("savedViewsId")`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "embeds_saved_views_saved_views" ADD CONSTRAINT "FK_embeds_saved_views_embeds" FOREIGN KEY ("embedsId") REFERENCES "embeds"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "embeds_saved_views_saved_views" ADD CONSTRAINT "FK_embeds_saved_views_saved_views" FOREIGN KEY ("savedViewsId") REFERENCES "saved_views"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "embeds_saved_views_saved_views" DROP CONSTRAINT "FK_embeds_saved_views_saved_views"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "embeds_saved_views_saved_views" DROP CONSTRAINT "FK_embeds_saved_views_embeds"`,
+ );
+ await queryRunner.query(`DROP INDEX "IDX_embeds_saved_views_saved_views"`);
+ await queryRunner.query(`DROP INDEX "IDX_embeds_saved_views_embeds"`);
+ await queryRunner.query(`DROP TABLE "embeds_saved_views_saved_views"`);
+ }
+}
diff --git a/studio/features/db/migrations/1750719230084-add-zoom-to-saved-views.ts b/studio/features/db/migrations/1750719230084-add-zoom-to-saved-views.ts
new file mode 100644
index 00000000..0744de51
--- /dev/null
+++ b/studio/features/db/migrations/1750719230084-add-zoom-to-saved-views.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddZoomToSavedViews1750719230084 implements MigrationInterface {
+ name = "AddZoomToSavedViews1750719230084";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "saved_views" ADD "projection_type" character varying NOT NULL DEFAULT 'ORTHOGRAPHIC'`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "saved_views" ADD "fov_y_radian" double precision NOT NULL DEFAULT 0.7853981633974483`,
+ );
+ await queryRunner.query(`ALTER TABLE "saved_views" ADD "orthographic_left" double precision NOT NULL DEFAULT -1`);
+ await queryRunner.query(`ALTER TABLE "saved_views" ADD "orthographic_right" double precision NOT NULL DEFAULT 1`);
+ await queryRunner.query(`ALTER TABLE "saved_views" ADD "orthographic_bottom" double precision NOT NULL DEFAULT -1`);
+ await queryRunner.query(`ALTER TABLE "saved_views" ADD "orthographic_top" double precision NOT NULL DEFAULT 1`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "orthographic_top"`);
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "orthographic_bottom"`);
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "orthographic_right"`);
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "orthographic_left"`);
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "fov_y_radian"`);
+ await queryRunner.query(`ALTER TABLE "saved_views" DROP COLUMN "projection_type"`);
+ }
+}
diff --git a/studio/features/editor-exports/components/EditorExportsCreate.tsx b/studio/features/editor-exports/components/EditorExportsCreate.tsx
index 8b104edc..231061f5 100644
--- a/studio/features/editor-exports/components/EditorExportsCreate.tsx
+++ b/studio/features/editor-exports/components/EditorExportsCreate.tsx
@@ -3,6 +3,7 @@
import {
ActionBar,
ActionBarContainer,
+ Checkbox,
Content,
Dialog,
DialogContainer,
@@ -22,30 +23,40 @@ import uploadEmbed from "@features/api-sdk/uploadEmbed";
import useMetadataContext from "@features/editor-metadata/hooks/useMetadataContext";
import useExportEmbed from "@features/editor/hooks/useExportModels";
import { useRenderer } from "@features/editor/hooks/useRender";
-import { useCallback, useState } from "react";
+import { useSavedViews } from "@features/saved-views/hooks/useSavedViews";
+import { useCallback, useEffect, useState } from "react";
type EditorExportsCreateProps = {
sanitizedId: number;
};
-export default function EditorExportsCreate({
- sanitizedId,
-}: EditorExportsCreateProps) {
+export default function EditorExportsCreate({ sanitizedId }: EditorExportsCreateProps) {
const { columns } = useMetadataContext();
+ const { savedViews, fetchSavedViews } = useSavedViews(sanitizedId);
const [name, setName] = useState("Untitled Embed");
const [selectedKeys, setSelectedKeys] = useState([]);
+ const [selectedSavedViewKeys, setSelectedSavedViewKeys] = useState([]);
const [isSavingDialogOpen, setIsSavingDialogOpen] = useState(false);
+ const [onlyTooltipInfo, setOnlyTooltipInfo] = useState(false);
const exportEmbeds = useExportEmbed();
const renderer = useRenderer();
+ useEffect(() => {
+ fetchSavedViews();
+ }, [fetchSavedViews]);
+
const saveEmbed = useCallback(() => {
- async function handleUploadEmbed(
- dataFile: File,
- thumbnailFileContents: string,
- ) {
- await uploadEmbed(sanitizedId, dataFile, thumbnailFileContents, name);
+ async function handleUploadEmbed(dataFile: File, thumbnailFileContents: string) {
+ await uploadEmbed(
+ sanitizedId,
+ dataFile,
+ thumbnailFileContents,
+ name,
+ onlyTooltipInfo,
+ selectedSavedViewKeys.map((id) => parseInt(id)),
+ );
setIsSavingDialogOpen(false);
}
@@ -57,13 +68,13 @@ export default function EditorExportsCreate({
const image = canvas.toDataURL("image/png");
//export project data
- const dataFile = exportEmbeds(new Set(selectedKeys));
+ const dataFile = exportEmbeds(new Set(selectedKeys), onlyTooltipInfo);
if (!dataFile) return;
//upload project version
void handleUploadEmbed(dataFile, image);
};
- }, [sanitizedId, name, exportEmbeds, renderer, selectedKeys]);
+ }, [sanitizedId, name, exportEmbeds, renderer, selectedKeys, selectedSavedViewKeys, onlyTooltipInfo]);
const handleGlobalAction = useCallback(
(key: Key) => {
@@ -78,24 +89,62 @@ export default function EditorExportsCreate({
-
+
+
+ Only include tooltip information
+
+
+
+ {/* Saved Views Selection */}
+
+
+ Choose saved views to include (optional)
+
+
+
+
+ {
+ if (keys === "all") {
+ setSelectedSavedViewKeys(savedViews.map((item) => item.id.toString()));
+ } else {
+ setSelectedSavedViewKeys(Array.from(keys) as string[]);
+ }
+ }}
+ >
+ {(item) => (
+ -
+ {item.name}
+
+ )}
+
+
+
+ {/* Columns Selection */}
-
+
{
if (!name) {
- ToastQueue.negative("Column name is required");
+ ToastQueue.negative("Column name is required", toasterOptions);
return;
}
try {
onSubmit(name, defaultValue, defaultType);
- ToastQueue.positive("Column created successfully");
+ ToastQueue.positive("Column created successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to create column");
+ ToastQueue.negative("Failed to create column", toasterOptions);
}
}, [name, defaultValue, defaultType, onSubmit, close]);
diff --git a/studio/features/editor-metadata/components/EditorMetadataAddValueDialog.tsx b/studio/features/editor-metadata/components/EditorMetadataAddValueDialog.tsx
index b234f59f..1bbcd02f 100644
--- a/studio/features/editor-metadata/components/EditorMetadataAddValueDialog.tsx
+++ b/studio/features/editor-metadata/components/EditorMetadataAddValueDialog.tsx
@@ -9,6 +9,7 @@ import {
RadioGroup,
TextField,
} from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useState } from "react";
@@ -26,17 +27,17 @@ export default function AddValueDialog({
const handleSubmit = useCallback(async () => {
if (!value) {
- ToastQueue.negative("Value is required");
+ ToastQueue.negative("Value is required", toasterOptions);
return;
}
try {
onSubmit(value, type);
- ToastQueue.positive("Value added successfully");
+ ToastQueue.positive("Value added successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to add value");
+ ToastQueue.negative("Failed to add value", toasterOptions);
}
}, [value, type, onSubmit, close]);
diff --git a/studio/features/editor-metadata/components/EditorMetadataColorPaletteDialog.tsx b/studio/features/editor-metadata/components/EditorMetadataColorPaletteDialog.tsx
index d1328966..4576469a 100644
--- a/studio/features/editor-metadata/components/EditorMetadataColorPaletteDialog.tsx
+++ b/studio/features/editor-metadata/components/EditorMetadataColorPaletteDialog.tsx
@@ -13,6 +13,7 @@ import {
import { Style } from "@features/editor/data/types";
import { useEditorContext } from "@features/editor/hooks/useEditorContext";
import { parseColor } from "@react-spectrum/color";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useEffect, useMemo, useState } from "react";
import { colorMaps } from "../constants";
@@ -95,11 +96,11 @@ export default function ColorPaletteDialog({ close }: AddValueDialogProps) {
return next;
});
- ToastQueue.positive("Color map applied successfully");
+ ToastQueue.positive("Color map applied successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Applying color map failed");
+ ToastQueue.negative("Applying color map failed", toasterOptions);
}
}, [value, close, setStyles, activeMetadataColumn, aggregatedRows, range]);
diff --git a/studio/features/editor-metadata/components/EditorMetadataImport.tsx b/studio/features/editor-metadata/components/EditorMetadataImport.tsx
index 9a82ec7c..34d94a62 100644
--- a/studio/features/editor-metadata/components/EditorMetadataImport.tsx
+++ b/studio/features/editor-metadata/components/EditorMetadataImport.tsx
@@ -9,6 +9,7 @@ import {
} from "@adobe/react-spectrum";
import { NoData } from "@core/components/Empty";
import { PositioningContainer } from "@core/components/PositioningContainer";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useMemo, useState } from "react";
import useMetadataContext from "../hooks/useMetadataContext";
@@ -46,7 +47,7 @@ export default function EditorMetadataImport() {
targetMetadataColumn,
removeExisting,
);
- ToastQueue.positive("Data mapped successfully");
+ ToastQueue.positive("Data mapped successfully", toasterOptions);
}, [
handleMapping,
sourceMetadataColumn,
diff --git a/studio/features/editor-metadata/components/EditorMetadataImportButton.tsx b/studio/features/editor-metadata/components/EditorMetadataImportButton.tsx
index 3d803f73..bb4d5047 100644
--- a/studio/features/editor-metadata/components/EditorMetadataImportButton.tsx
+++ b/studio/features/editor-metadata/components/EditorMetadataImportButton.tsx
@@ -10,6 +10,7 @@ import {
ProgressCircle,
Text,
} from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { parse } from "csv-parse/sync";
import { useCallback, useState } from "react";
@@ -32,7 +33,7 @@ export default function EditorMetadataImportButton({
setLoading(true);
if (modelList.length === 0) {
setLoading(false);
- ToastQueue.info("No table selected");
+ ToastQueue.info("No table selected", toasterOptions);
return;
}
diff --git a/studio/features/editor-models/components/EditorAddModelButton.tsx b/studio/features/editor-models/components/EditorAddModelButton.tsx
index 826a1d9d..43ab32d5 100644
--- a/studio/features/editor-models/components/EditorAddModelButton.tsx
+++ b/studio/features/editor-models/components/EditorAddModelButton.tsx
@@ -12,6 +12,7 @@ import {
} from "@adobe/react-spectrum";
import { useImportModels } from "@features/editor/hooks/useImportModels";
import { load } from "@features/editor/utils/formats/loader";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useState } from "react";
@@ -40,7 +41,7 @@ export default function EditorAddModelButton({}: EditorAddModelDialogProps) {
setLoading(true);
if (modelList.length === 0) {
setLoading(false);
- ToastQueue.info("No models selected");
+ ToastQueue.info("No models selected", toasterOptions);
return;
}
const fileMap = new Map();
diff --git a/studio/features/editor-saved-views/README.md b/studio/features/editor-saved-views/README.md
new file mode 100644
index 00000000..99c402da
--- /dev/null
+++ b/studio/features/editor-saved-views/README.md
@@ -0,0 +1,28 @@
+# Saved Views Feature
+
+This feature allows users to save and restore camera positions in the 3D editor.
+
+## Components
+
+- `EditorSavedViews.tsx` - Main component for managing saved views
+- `MdiBookmark.tsx` - Bookmark icon for the saved views tab
+
+## Features
+
+- Save current camera position with a custom name
+- List all saved views with timestamps
+- Load saved views to restore camera position
+- Rename saved views
+- Delete saved views
+- Multi-select and bulk delete views
+
+## TODO
+
+- Integrate with camera system to actually save/restore camera positions
+- Persist saved views to database
+- Add view thumbnails
+- Add view categories/tags
+
+## Usage
+
+The saved views tab is accessible from the main editor side panel, positioned between the "Style" and "Exports" tabs.
diff --git a/studio/features/editor-saved-views/components/EditorSavedViews.tsx b/studio/features/editor-saved-views/components/EditorSavedViews.tsx
new file mode 100644
index 00000000..e85cdcd5
--- /dev/null
+++ b/studio/features/editor-saved-views/components/EditorSavedViews.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import {
+ ActionBar,
+ ActionBarContainer,
+ ActionGroup,
+ Button,
+ Flex,
+ Item,
+ ListView,
+ Text,
+ TextField,
+ Tooltip,
+ TooltipTrigger,
+ View,
+} from "@adobe/react-spectrum";
+import { NoData } from "@core/components/Empty";
+import { PositioningContainer } from "@core/components/PositioningContainer";
+import { MdiBookmark } from "@core/icons/MdiBookmark";
+import { MdiRename } from "@core/icons/MdiRename";
+import { MdiTrash } from "@core/icons/MdiTrash";
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+import { useEditorContext } from "@features/editor/hooks/useEditorContext";
+import { useSavedViews } from "@features/saved-views/hooks/useSavedViews";
+import { Key, useCallback, useEffect, useState } from "react";
+
+type EditorSavedViewsProps = {
+ projectId: number;
+};
+
+export default function EditorSavedViews({ projectId }: EditorSavedViewsProps) {
+ const { savedViews, loading, error, fetchSavedViews, createView, updateView, deleteView } = useSavedViews(projectId);
+ const { renderer, activeView } = useEditorContext();
+ const [selectedKeys, setSelectedKeys] = useState>(new Set());
+ const [editingView, setEditingView] = useState(null);
+ const [newViewName, setNewViewName] = useState("Untitled View");
+
+ useEffect(() => {
+ fetchSavedViews();
+ }, [fetchSavedViews]);
+
+ const handleSelection = useCallback((keys: any) => {
+ setSelectedKeys(keys);
+ }, []);
+
+ const handleSaveCurrentView = useCallback(async () => {
+ try {
+ // Get current camera position from the editor context (same as embed export)
+ const view = renderer.views?.[activeView];
+ if (!view) {
+ console.error("No active view found");
+ return;
+ }
+
+ const cameraPosition: [number, number, number] = [
+ view.view.camera.position[0],
+ view.view.camera.position[1],
+ view.view.camera.position[2],
+ ];
+
+ const cameraTarget: [number, number, number] = [
+ view.view.camera.target[0],
+ view.view.camera.target[1],
+ view.view.camera.target[2],
+ ];
+
+ // Capture zoom-related data
+ const projectionType = view.view.camera.projectionType;
+ const fovYRadian = view.view.camera.fovYRadian;
+
+ // Get orthographic bounds using the getter methods from the camera class
+ const orthographicLeft = view.view.camera.orthographicLeft;
+ const orthographicRight = view.view.camera.orthographicRight;
+ const orthographicBottom = view.view.camera.orthographicBottom;
+ const orthographicTop = view.view.camera.orthographicTop;
+
+ await createView(
+ newViewName || `View ${savedViews.length + 1}`,
+ cameraPosition,
+ cameraTarget,
+ projectionType,
+ fovYRadian,
+ orthographicLeft,
+ orthographicRight,
+ orthographicBottom,
+ orthographicTop,
+ );
+ setNewViewName("Untitled View");
+ } catch (err) {
+ console.error("Failed to save view:", err);
+ }
+ }, [newViewName, savedViews.length, createView, renderer.views, activeView]);
+
+ const handleLoadView = useCallback(
+ (view: SavedView) => {
+ // Set camera position in the editor context (same as embed export)
+ const currentView = renderer.views?.[activeView];
+ if (!currentView) {
+ console.error("No active view found");
+ return;
+ }
+
+ // Set camera position and target to the saved view values
+ currentView.view.camera.set({
+ position: view.cameraPosition,
+ target: view.cameraTarget,
+ projectionType: view.projectionType,
+ fovYRadian: view.fovYRadian,
+ });
+
+ // For orthographic projection, set the saved orthographic bounds
+ if (view.projectionType === ProjectionType.ORTHOGRAPHIC) {
+ currentView.view.camera.setOrthographicBounds(
+ view.orthographicLeft,
+ view.orthographicRight,
+ view.orthographicBottom,
+ view.orthographicTop,
+ );
+ }
+
+ // Update the camera matrices to reflect the new position
+ currentView.view.camera.updateProjectionViewMatrix();
+
+ console.log("Loaded view:", view.name);
+ },
+ [renderer.views, activeView],
+ );
+
+ const handleDeleteView = useCallback(
+ async (view: SavedView) => {
+ try {
+ await deleteView(view.id);
+ } catch (err) {
+ console.error("Failed to delete view:", err);
+ }
+ },
+ [deleteView],
+ );
+
+ const handleRenameView = useCallback(
+ async (view: SavedView, newName: string) => {
+ try {
+ await updateView(view.id, { name: newName });
+ setEditingView(null);
+ } catch (err) {
+ console.error("Failed to rename view:", err);
+ }
+ },
+ [updateView],
+ );
+
+ const handleItemAction = useCallback(
+ (key: Key, view: SavedView) => {
+ switch (key) {
+ case "load":
+ handleLoadView(view);
+ break;
+ case "rename":
+ setEditingView(view);
+ break;
+ case "delete":
+ handleDeleteView(view);
+ break;
+ }
+ },
+ [handleLoadView, handleDeleteView],
+ );
+
+ const selectedCount = selectedKeys.size;
+
+ if (loading) {
+ return (
+
+
+ Loading saved views...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Error: {error}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {editingView && (
+
+
+ setEditingView((prev) => (prev ? { ...prev, name: value } : null))}
+ autoFocus
+ />
+
+
+
+
+
+
+ )}
+
+
+
+ }
+ >
+ {(view) => (
+ -
+ {view.name}
+
+ handleItemAction(key, view)}>
+
+
-
+
+
+ Load view
+
+
+ -
+
+
+ Rename view
+
+
+ -
+
+
+ Delete view
+
+
+
+ )}
+
+ {selectedKeys.size > 0 && (
+ setSelectedKeys(new Set())}
+ >
+ -
+
+ Delete views
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/studio/features/editor-saved-views/index.ts b/studio/features/editor-saved-views/index.ts
new file mode 100644
index 00000000..dc149f48
--- /dev/null
+++ b/studio/features/editor-saved-views/index.ts
@@ -0,0 +1 @@
+export { default as EditorSavedViews } from "./components/EditorSavedViews";
diff --git a/studio/features/editor-toolbar/components/ActiveColumnToolbar.tsx b/studio/features/editor-toolbar/components/ActiveColumnToolbar.tsx
index b562c91f..d5c50122 100644
--- a/studio/features/editor-toolbar/components/ActiveColumnToolbar.tsx
+++ b/studio/features/editor-toolbar/components/ActiveColumnToolbar.tsx
@@ -1,11 +1,23 @@
import { ComboBox, Item, View } from "@adobe/react-spectrum";
import useMetadataContext from "@features/editor-metadata/hooks/useMetadataContext";
import { useEditorContext } from "@features/editor/hooks/useEditorContext";
+import { useMemo } from "react";
export default function ActiveColumnToolbar() {
const { columns } = useMetadataContext();
const { activeMetadataColumn, setActiveMetadataColumn } = useEditorContext();
+ // Find the label for the currently selected key
+ const selectedLabel = useMemo(() => {
+ const found = columns.find((col) => col.key === activeMetadataColumn);
+ return found ? found.key : "";
+ }, [columns, activeMetadataColumn]);
+
+ // If there's only one column, don't show the dropdown
+ if (columns.length <= 1) {
+ return null;
+ }
+
return (
- setActiveMetadataColumn(key?.toString() || "")
- }
+ onSelectionChange={(key) => setActiveMetadataColumn(key?.toString() || "")}
selectedKey={activeMetadataColumn}
+ inputValue={selectedLabel}
+ onInputChange={() => {}}
+ allowsCustomValue={false}
>
{(item) => - {item.key}
}
diff --git a/studio/features/editor-toolbar/components/CameraViewToolbar.tsx b/studio/features/editor-toolbar/components/CameraViewToolbar.tsx
index be0abd84..c7028dfc 100644
--- a/studio/features/editor-toolbar/components/CameraViewToolbar.tsx
+++ b/studio/features/editor-toolbar/components/CameraViewToolbar.tsx
@@ -1,11 +1,4 @@
-import {
- ActionGroup,
- Item,
- Selection,
- Tooltip,
- TooltipTrigger,
- View,
-} from "@adobe/react-spectrum";
+import { ActionGroup, Item, Selection, Tooltip, TooltipTrigger, View } from "@adobe/react-spectrum";
import { CameraView } from "@bananagl/bananagl";
import { CubeEmpty } from "@core/icons/CubeEmpty";
import { CubeLeft } from "@core/icons/CubeLeft";
@@ -15,7 +8,7 @@ import { CubeTop } from "@core/icons/CubeTop";
import { useEditorContext } from "@features/editor/hooks/useEditorContext";
import { useCallback } from "react";
-export default function CameraViewToolbar() {
+export default function CameraViewToolbar({ embedMode = false }: { embedMode?: boolean }) {
const { viewMode, setViewMode } = useEditorContext();
const handleAction = useCallback(
@@ -24,14 +17,51 @@ export default function CameraViewToolbar() {
if (keys === "all") return;
//get first key
- const viewMode =
- (keys.values().next().value as CameraView) ?? CameraView.Free;
+ const viewMode = (keys.values().next().value as CameraView) ?? CameraView.Free;
setViewMode(viewMode);
},
-
[setViewMode],
);
+ // Camera options
+ const cameraOptions = [
+ {
+ key: CameraView.Free,
+ icon: ,
+ label: "Free camera",
+ },
+ {
+ key: CameraView.Top,
+ icon: ,
+ label: "Top view",
+ },
+ // Only show these in editor mode
+ ...(!embedMode
+ ? [
+ {
+ key: CameraView.Front,
+ icon: ,
+ label: "Front view",
+ },
+ {
+ key: CameraView.Right,
+ icon: ,
+ label: "Right view",
+ },
+ {
+ key: CameraView.Left,
+ icon: ,
+ label: "Left view",
+ },
+ {
+ key: CameraView.Back,
+ icon: ,
+ label: "Back view",
+ },
+ ]
+ : []),
+ ];
+
return (
-
- -
-
-
- Free camera
-
-
- -
-
-
- Top view
-
-
- -
-
-
- Front view
-
-
- -
-
-
- Right view
-
-
- -
-
-
- Left view
-
-
- -
-
-
- Back view
-
+ {cameraOptions.map((opt) => (
+
+ - {opt.icon}
+ {opt.label}
+
+ ))}
);
diff --git a/studio/features/editor-toolbar/components/SavedViewsToolbar.tsx b/studio/features/editor-toolbar/components/SavedViewsToolbar.tsx
new file mode 100644
index 00000000..f34445f0
--- /dev/null
+++ b/studio/features/editor-toolbar/components/SavedViewsToolbar.tsx
@@ -0,0 +1,82 @@
+import { Item, Picker, Tooltip, TooltipTrigger, View } from "@adobe/react-spectrum";
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+import { useEditorContext } from "@features/editor/hooks/useEditorContext";
+import { useCallback } from "react";
+
+type SavedViewsToolbarProps = {
+ savedViews?: SavedView[];
+ embedMode?: boolean;
+};
+
+export default function SavedViewsToolbar({ savedViews = [], embedMode = false }: SavedViewsToolbarProps) {
+ const { renderer, activeView } = useEditorContext();
+
+ const handleSelectionChange = useCallback(
+ (selectedKey: string | number) => {
+ if (!selectedKey) return;
+
+ const selectedView = savedViews.find((view) => view.id.toString() === selectedKey.toString());
+
+ if (!selectedView) return;
+
+ // Set camera position in the editor context
+ const currentView = renderer.views?.[activeView];
+ if (!currentView) {
+ console.error("No active view found");
+ return;
+ }
+
+ // Set camera position and target to the saved view values
+ currentView.view.camera.set({
+ position: selectedView.cameraPosition,
+ target: selectedView.cameraTarget,
+ projectionType: selectedView.projectionType,
+ fovYRadian: selectedView.fovYRadian,
+ });
+
+ // For orthographic projection, set the saved orthographic bounds
+ if (selectedView.projectionType === ProjectionType.ORTHOGRAPHIC) {
+ currentView.view.camera.setOrthographicBounds(
+ selectedView.orthographicLeft,
+ selectedView.orthographicRight,
+ selectedView.orthographicBottom,
+ selectedView.orthographicTop,
+ );
+ }
+
+ // Update the camera matrices to reflect the new position
+ currentView.view.camera.updateProjectionViewMatrix();
+
+ console.log("Loaded saved view:", selectedView.name);
+ },
+ [renderer.views, activeView, savedViews],
+ );
+
+ // Only show in embed mode and if there are saved views
+ if (!embedMode || savedViews.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {(item) => (
+ -
+ {item.name}
+
+ )}
+
+ Select saved view
+
+
+ );
+}
diff --git a/studio/features/editor-toolbar/components/ScreenshotToolbar.tsx b/studio/features/editor-toolbar/components/ScreenshotToolbar.tsx
new file mode 100644
index 00000000..b4054c32
--- /dev/null
+++ b/studio/features/editor-toolbar/components/ScreenshotToolbar.tsx
@@ -0,0 +1,51 @@
+import { ActionGroup, Item, Tooltip, TooltipTrigger, View } from "@adobe/react-spectrum";
+import { MdiCamera } from "@core/icons/MdiCamera";
+import { useRenderer } from "@features/editor/hooks/useRender";
+import { useCallback } from "react";
+
+export default function ScreenshotToolbar() {
+ const renderer = useRenderer();
+
+ const handleScreenshot = useCallback(() => {
+ if (!renderer) return;
+
+ // Use the renderer's afterRenderOnce callback to capture the canvas
+ renderer.afterRenderOnce = () => {
+ const canvas = renderer.window.rawCanvas;
+ if (!canvas) return;
+
+ // Convert canvas to data URL
+ const image = canvas.toDataURL("image/png");
+
+ // Create download link
+ const link = document.createElement("a");
+ link.download = `Metacity-screenshot-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.png`;
+ link.href = image;
+
+ // Trigger download
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+ }, [renderer]);
+
+ return (
+
+
+
+ -
+
+
+ Take screenshot
+
+
+
+ );
+}
diff --git a/studio/features/editor/components/Canvas/TooltipOverlay.tsx b/studio/features/editor/components/Canvas/TooltipOverlay.tsx
index bcbbe255..778a222c 100644
--- a/studio/features/editor/components/Canvas/TooltipOverlay.tsx
+++ b/studio/features/editor/components/Canvas/TooltipOverlay.tsx
@@ -1,13 +1,91 @@
import { Heading, Text, View } from "@adobe/react-spectrum";
import { useEditorContext } from "@features/editor/hooks/useEditorContext";
+import { useEffect, useRef, useState } from "react";
-export function TooltipOverlay() {
+// Function to detect URLs in text
+const detectUrls = (text: string | number | null | undefined) => {
+ if (typeof text !== "string") text = String(text ?? "");
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ const parts = text.split(urlRegex);
+ return parts.map((part, index) => {
+ if (part.match(urlRegex)) {
+ return (
+ e.stopPropagation()}
+ style={{ color: "#0078D4", textDecoration: "underline" }}
+ >
+ {part}
+
+ );
+ }
+ return part;
+ });
+};
+
+export function TooltipOverlay({ onlyTooltipInfo = false }: { onlyTooltipInfo?: boolean }) {
const { tooltip, activeMetadataColumn } = useEditorContext();
+ const [visibleTooltip, setVisibleTooltip] = useState(tooltip);
+ const hideTimeout = useRef(null);
+
+ // Effect to handle tooltip show/hide based on context
+ useEffect(() => {
+ if (tooltip) {
+ setVisibleTooltip(tooltip);
+ if (hideTimeout.current) {
+ clearTimeout(hideTimeout.current);
+ hideTimeout.current = null;
+ }
+ } else if (visibleTooltip) {
+ if (!hideTimeout.current) {
+ hideTimeout.current = setTimeout(() => {
+ setVisibleTooltip(null);
+ hideTimeout.current = null;
+ }, 200); // 2s delay
+ }
+ }
+ return () => {
+ if (hideTimeout.current) {
+ clearTimeout(hideTimeout.current);
+ hideTimeout.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tooltip, visibleTooltip]);
+
+ // Handler for mouse enter/leave on the tooltip itself
+ const handleMouseEnter = () => {
+ if (hideTimeout.current) {
+ clearTimeout(hideTimeout.current);
+ hideTimeout.current = null;
+ }
+ };
- if (!tooltip) return null;
+ const handleMouseLeave = () => {
+ if (!hideTimeout.current) {
+ hideTimeout.current = setTimeout(() => {
+ setVisibleTooltip(null);
+ hideTimeout.current = null;
+ }, 200); // 2s delay
+ }
+ };
- const value = tooltip.data[activeMetadataColumn];
- //if (value === undefined) return null;
+ if (!visibleTooltip) return null;
+
+ let content;
+ if (onlyTooltipInfo) {
+ content = Object.entries(visibleTooltip.data).map(([key, value]) => ({
+ key,
+ value: value ?? "N/A",
+ }));
+ } else {
+ content = visibleTooltip.data[activeMetadataColumn] ?? "N/A";
+ }
+
+ if (!content) content = "N/A";
return (
-
-
-
- {activeMetadataColumn}
-
-
-
- {value ?? "N/A"}
+
+ {!onlyTooltipInfo && (
+
+
+ {activeMetadataColumn}
+
+
+ )}
+
+
+ {onlyTooltipInfo ? (
+
+ {content.map((item: { key: string; value: string }, i: number) => (
+
+
{item.key}:
+
{detectUrls(item.value)}
+
+ ))}
+
+ ) : (
+ detectUrls(content)
+ )}
+
+
-
+
);
}
diff --git a/studio/features/editor/components/Editor.tsx b/studio/features/editor/components/Editor.tsx
index 6cff9b2e..26591632 100644
--- a/studio/features/editor/components/Editor.tsx
+++ b/studio/features/editor/components/Editor.tsx
@@ -1,16 +1,10 @@
"use client";
-import {
- Grid,
- Item,
- TabList,
- TabPanels,
- Tabs,
- View,
-} from "@adobe/react-spectrum";
+import { Grid, Item, TabList, TabPanels, Tabs, View } from "@adobe/react-spectrum";
import { ToastContainer } from "@react-spectrum/toast";
//import Brush from "@spectrum-icons/workflow/Brush";
import { PositioningContainer } from "@core/components/PositioningContainer";
+import { MdiBookmark } from "@core/icons/MdiBookmark";
import { MdiCube } from "@core/icons/MdiCube";
import { MdiExport } from "@core/icons/MdiExport";
import { MdiPalette } from "@core/icons/MdiPalette";
@@ -20,10 +14,12 @@ import EditorColumns from "@features/editor-metadata/components/EditorColumns";
import EditorStyle from "@features/editor-metadata/components/EditorStyle";
import useMetadataModelStyle from "@features/editor-metadata/hooks/useMetadataModelStyle";
import EditorModels from "@features/editor-models/components/EditorModels";
+import { EditorSavedViews } from "@features/editor-saved-views";
import ActiveColumnToolbar from "@features/editor-toolbar/components/ActiveColumnToolbar";
import CameraViewToolbar from "@features/editor-toolbar/components/CameraViewToolbar";
import ColorSchemeToolbar from "@features/editor-toolbar/components/ColorSchemeToolbar";
import ProjectionToolbar from "@features/editor-toolbar/components/ProjectionToolbar";
+import ScreenshotToolbar from "@features/editor-toolbar/components/ScreenshotToolbar";
import SelectionToolbar from "@features/editor-toolbar/components/SelectionToolbar";
import { Allotment } from "allotment";
import "allotment/dist/style.css";
@@ -47,8 +43,8 @@ export default function Editor(props: EditorProps) {
+
@@ -74,11 +71,7 @@ function SidePanel(props: SidePanelProps) {
return (
-
+
+ -
+
+
-
@@ -117,6 +113,9 @@ function SidePanel(props: SidePanelProps) {
-
+ -
+
+
-
diff --git a/studio/features/editor/hooks/useExportModels.ts b/studio/features/editor/hooks/useExportModels.ts
index c69ad278..e3349b50 100644
--- a/studio/features/editor/hooks/useExportModels.ts
+++ b/studio/features/editor/hooks/useExportModels.ts
@@ -40,7 +40,7 @@ export default function useExportEmbed() {
const ctx = useEditorContext();
const exportProject = useCallback(
- (selectedColumns: Set) => {
+ (selectedColumns: Set, onlyTooltipInfo: boolean = false) => {
const modelData = extractModels(models);
if (!modelData) return;
diff --git a/studio/features/editor/hooks/useSplitModel.ts b/studio/features/editor/hooks/useSplitModel.ts
index 3cad4e6c..a788beb7 100644
--- a/studio/features/editor/hooks/useSplitModel.ts
+++ b/studio/features/editor/hooks/useSplitModel.ts
@@ -15,7 +15,9 @@ export function useSplitModel() {
async (oldModel: EditorModel, submodels: Set) => {
const newModels = splitModel(oldModel, submodels);
if (!newModels) return;
- await importModels(newModels);
+ await importModels(newModels, {
+ disableShift: true,
+ });
removeModels([oldModel]);
},
[importModels, removeModels],
diff --git a/studio/features/embeds/mutations/createEmbed.ts b/studio/features/embeds/mutations/createEmbed.ts
index a4ea5725..af8417da 100644
--- a/studio/features/embeds/mutations/createEmbed.ts
+++ b/studio/features/embeds/mutations/createEmbed.ts
@@ -1,48 +1,73 @@
import { canEditProject } from "@features/auth/acl";
import { Embed } from "@features/db/entities/embed";
+import { SavedView } from "@features/db/entities/savedView";
import { injectRepository } from "@features/db/helpers";
import { toPlain } from "@features/helpers/objects";
-import {
- ensureBucket,
- getEmbedBucketName,
- saveFileStream,
-} from "@features/storage";
+import { ensureBucket, getEmbedBucketName, saveFileStream } from "@features/storage";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import { ReadableStream } from "stream/web";
+import { In } from "typeorm";
export async function createEmbed(
projectId: number,
name: string,
file: File,
thumbnailFileContents: string,
+ onlyTooltipInfo: boolean = false,
+ savedViewIds: number[] = [],
) {
if (!(await canEditProject())) throw new Error("Unauthorized");
const embedRepository = await injectRepository(Embed);
+ const savedViewRepository = await injectRepository(SavedView);
const versionFileName = randomUUID();
-
const bucketName = getEmbedBucketName(versionFileName);
- // create the embed in the database
- const embed = await embedRepository.save({
+ // Get saved views if IDs are provided
+ let savedViews: SavedView[] = [];
+ if (savedViewIds.length > 0) {
+ savedViews = await savedViewRepository.find({
+ where: { id: In(savedViewIds) },
+ });
+ }
+
+ // create the embed in the database (without saved views for now)
+ const embed = embedRepository.create({
project: { id: projectId },
name: name,
thumbnailContents: thumbnailFileContents,
bucketName: bucketName,
+ onlyTooltipInfo: onlyTooltipInfo,
});
+ // Save the embed
+ const savedEmbed = await embedRepository.save(embed);
+
+ // Use the relation API to set the many-to-many relationship
+ if (savedViews.length > 0) {
+ await embedRepository
+ .createQueryBuilder()
+ .relation(Embed, "savedViews")
+ .of(savedEmbed)
+ .add(savedViews.map((v) => v.id));
+ }
+
// save the files to the bucket
try {
await ensureBucket(embed.bucketName);
const fileStream = Readable.fromWeb(file.stream() as ReadableStream);
await saveFileStream(file.name, embed.bucketName, fileStream);
} catch (e) {
- await embedRepository.remove(embed);
+ await embedRepository.remove(savedEmbed);
throw e;
}
- // return the embed
- return toPlain(embed);
+ // return the embed with saved views
+ const finalEmbed = await embedRepository.findOne({
+ where: { id: savedEmbed.id },
+ relations: ["savedViews"],
+ });
+ return toPlain(finalEmbed);
}
diff --git a/studio/features/embeds/queries/getEmbed.ts b/studio/features/embeds/queries/getEmbed.ts
index 6c14cdbe..b23551c3 100644
--- a/studio/features/embeds/queries/getEmbed.ts
+++ b/studio/features/embeds/queries/getEmbed.ts
@@ -1,20 +1,15 @@
"use server";
-import { canReadProjects } from "@features/auth/acl";
-import { getUserToken } from "@features/auth/user";
import { Embed } from "@features/db/entities/embed";
import { injectRepository } from "@features/db/helpers";
import { toPlain } from "@features/helpers/objects";
export default async function getEmbed(id: number) {
- if (!(await canReadProjects())) throw new Error("Unauthorized");
-
- const user = (await getUserToken())!;
-
const embedRepository = await injectRepository(Embed);
const embed = await embedRepository.findOne({
- where: { id, project: { user: { id: user.id } } },
+ where: { id },
+ relations: ["savedViews"],
});
if (!embed) throw new Error("Not found");
diff --git a/studio/features/embeds/queries/getEmbedFile.ts b/studio/features/embeds/queries/getEmbedFile.ts
index 74f6833f..5d86c04e 100644
--- a/studio/features/embeds/queries/getEmbedFile.ts
+++ b/studio/features/embeds/queries/getEmbedFile.ts
@@ -8,14 +8,10 @@ import { injectRepository } from "@features/db/helpers";
import { listFilesInBucket } from "@features/storage";
export default async function getEnbedFile(id: number) {
- if (!(await canReadProjects())) throw new Error("Unauthorized");
-
- const user = (await getUserToken())!;
-
const embedRepository = await injectRepository(Embed);
const embed = await embedRepository.findOne({
- where: { id, project: { user: { id: user.id } } },
+ where: { id },
});
if (!embed) throw new Error("Not found");
diff --git a/studio/features/models/components/ModelConvertDialog.tsx b/studio/features/models/components/ModelConvertDialog.tsx
index dc9a2d91..9d9226b5 100644
--- a/studio/features/models/components/ModelConvertDialog.tsx
+++ b/studio/features/models/components/ModelConvertDialog.tsx
@@ -9,6 +9,7 @@ import {
Heading,
TextField,
} from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useState } from "react";
import { useModel } from "../hooks/useModel";
@@ -36,9 +37,9 @@ export default function ModelConvertDialog({
try {
const response = await convertModel(modelId, targetEPSG);
- ToastQueue.info("Converted model successfully created.");
+ ToastQueue.info("Converted model successfully created.", toasterOptions);
} catch (error) {
- ToastQueue.negative("Creating converted model failed.", {});
+ ToastQueue.negative("Creating converted model failed.", toasterOptions);
}
setIsConverting(false);
diff --git a/studio/features/models/components/ModelDeleteDialog.tsx b/studio/features/models/components/ModelDeleteDialog.tsx
index 5c4a5570..f3cea6f0 100644
--- a/studio/features/models/components/ModelDeleteDialog.tsx
+++ b/studio/features/models/components/ModelDeleteDialog.tsx
@@ -1,4 +1,5 @@
import { AlertDialog, DialogContainer } from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback } from "react";
import { useDeleteModel } from "../hooks/useDeleteModel";
@@ -18,17 +19,17 @@ export default function ModelDeleteDialog({
const handleDelete = useCallback(async () => {
if (!modelId) {
- ToastQueue.negative("Invalid model id");
+ ToastQueue.negative("Invalid model id", toasterOptions);
return;
}
try {
await call(modelId);
- ToastQueue.info("Model deleted successfully");
+ ToastQueue.info("Model deleted successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to delete model");
+ ToastQueue.negative("Failed to delete model", toasterOptions);
}
}, [call, close, modelId]);
diff --git a/studio/features/models/components/ModelRenameDialog.tsx b/studio/features/models/components/ModelRenameDialog.tsx
index 01e225d1..abb8e627 100644
--- a/studio/features/models/components/ModelRenameDialog.tsx
+++ b/studio/features/models/components/ModelRenameDialog.tsx
@@ -8,6 +8,7 @@ import {
Heading,
TextField,
} from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useEffect, useState } from "react";
import { useModel } from "../hooks/useModel";
@@ -36,7 +37,7 @@ export default function ModelRenameDialog({
const handleRename = useCallback(async () => {
if (!modelId) {
- ToastQueue.negative("Invalid model id");
+ ToastQueue.negative("Invalid model id", toasterOptions);
return;
}
@@ -46,11 +47,11 @@ export default function ModelRenameDialog({
try {
await call(modelId, data);
- ToastQueue.info("Model renamed successfully");
+ ToastQueue.info("Model renamed successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to rename model");
+ ToastQueue.negative("Failed to rename model"), toasterOptions;
}
}, [call, close, modelId, name]);
diff --git a/studio/features/models/components/ModelUploadDialog.tsx b/studio/features/models/components/ModelUploadDialog.tsx
index 12309bab..72eb7493 100644
--- a/studio/features/models/components/ModelUploadDialog.tsx
+++ b/studio/features/models/components/ModelUploadDialog.tsx
@@ -17,7 +17,7 @@ import {
TextField,
} from "@adobe/react-spectrum";
import uploadModel from "@features/api-sdk/uploadModel";
-
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { DropEvent } from "@react-types/shared";
import File from "@spectrum-icons/illustrations/File";
@@ -35,10 +35,10 @@ export default function ModelUploadDialog({ close }: UploadModelDialogProps) {
const handleSubmit = useCallback(async () => {
// check if files are empty
if (!files.length) {
- ToastQueue.negative("At least one file is required");
+ ToastQueue.negative("At least one file is required", toasterOptions);
}
if (!name) {
- ToastQueue.negative("Model name is required");
+ ToastQueue.negative("Model name is required", toasterOptions);
}
if (!files.length || !name) {
return;
@@ -51,7 +51,7 @@ export default function ModelUploadDialog({ close }: UploadModelDialogProps) {
if (response.status === 201) {
close();
} else {
- ToastQueue.negative("Failed to upload model");
+ ToastQueue.negative("Failed to upload model", toasterOptions);
}
}, [name, files, close]);
diff --git a/studio/features/projects/components/CreateDialog.tsx b/studio/features/projects/components/CreateDialog.tsx
index 5a0b6e14..8500022e 100644
--- a/studio/features/projects/components/CreateDialog.tsx
+++ b/studio/features/projects/components/CreateDialog.tsx
@@ -11,6 +11,7 @@ import {
TextField,
} from "@adobe/react-spectrum";
import { useCreateProjects } from "@features/projects/hooks/useCreateProject";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useState } from "react";
@@ -28,7 +29,7 @@ export default function CreateProjectDialog({
const handleSubmit = useCallback(async () => {
if (!name || !description) {
- ToastQueue.negative("Project name and description are required");
+ ToastQueue.negative("Project name and description are required", toasterOptions);
return;
}
@@ -39,11 +40,11 @@ export default function CreateProjectDialog({
try {
await call(data);
- ToastQueue.positive("Project created successfully");
+ ToastQueue.positive("Project created successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to create project");
+ ToastQueue.negative("Failed to create project", toasterOptions);
}
}, [name, description, call, close]);
diff --git a/studio/features/projects/components/DeleteDialog.tsx b/studio/features/projects/components/DeleteDialog.tsx
index 3772e2ca..c7f4a5f6 100644
--- a/studio/features/projects/components/DeleteDialog.tsx
+++ b/studio/features/projects/components/DeleteDialog.tsx
@@ -1,4 +1,5 @@
import { AlertDialog, DialogContainer } from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback } from "react";
import { useDeleteProject } from "../hooks/useDeleteProject";
@@ -18,17 +19,17 @@ export default function DeleteDialog({
const handleDelete = useCallback(async () => {
if (!projectId) {
- ToastQueue.negative("Invalid project id");
+ ToastQueue.negative("Invalid project id", toasterOptions);
return;
}
try {
await call(projectId);
- ToastQueue.info("Project deleted successfully");
+ ToastQueue.info("Project deleted successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to delete project");
+ ToastQueue.negative("Failed to delete project", toasterOptions);
}
}, [call, close, projectId]);
diff --git a/studio/features/projects/components/DuplicateDialog.tsx b/studio/features/projects/components/DuplicateDialog.tsx
index 2a1efdab..4b40c610 100644
--- a/studio/features/projects/components/DuplicateDialog.tsx
+++ b/studio/features/projects/components/DuplicateDialog.tsx
@@ -1,4 +1,5 @@
import { AlertDialog, DialogContainer } from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback } from "react";
import { useDuplicateProject } from "../hooks/useDuplicateProject";
@@ -18,17 +19,17 @@ export default function DuplicateDialog({
const handleDelete = useCallback(async () => {
if (!projectId) {
- ToastQueue.negative("Invalid project id");
+ ToastQueue.negative("Invalid project id", toasterOptions);
return;
}
try {
await call(projectId);
- ToastQueue.info("Project duplicated successfully");
+ ToastQueue.info("Project duplicated successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to duplicate project");
+ ToastQueue.negative("Failed to duplicate project", toasterOptions);
}
}, [call, close, projectId]);
diff --git a/studio/features/projects/components/EditDialog.tsx b/studio/features/projects/components/EditDialog.tsx
index 8e4e48a1..c6715bd2 100644
--- a/studio/features/projects/components/EditDialog.tsx
+++ b/studio/features/projects/components/EditDialog.tsx
@@ -9,6 +9,7 @@ import {
TextArea,
TextField,
} from "@adobe/react-spectrum";
+import { toasterOptions } from "@core/defaults";
import { ToastQueue } from "@react-spectrum/toast";
import { useCallback, useEffect, useState } from "react";
import { useGetProjectById } from "../hooks/useGetProjectById";
@@ -40,12 +41,12 @@ export default function EditDialog({
const handleSubmit = useCallback(async () => {
if (!name || !description) {
- ToastQueue.negative("Project name and description are required");
+ ToastQueue.negative("Project name and description are required", toasterOptions);
return;
}
if (!projectId) {
- ToastQueue.negative("Invalid project id");
+ ToastQueue.negative("Invalid project id", toasterOptions);
return;
}
@@ -56,11 +57,11 @@ export default function EditDialog({
try {
await call(projectId, data);
- ToastQueue.positive("Project updated successfully");
+ ToastQueue.positive("Project updated successfully", toasterOptions);
close();
} catch (error) {
console.error(error);
- ToastQueue.negative("Failed to create project");
+ ToastQueue.negative("Failed to create project", toasterOptions);
}
}, [name, description, projectId, call, close]);
diff --git a/studio/features/saved-views/hooks/useSavedViews.ts b/studio/features/saved-views/hooks/useSavedViews.ts
new file mode 100644
index 00000000..e2af8c5a
--- /dev/null
+++ b/studio/features/saved-views/hooks/useSavedViews.ts
@@ -0,0 +1,111 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+import { useCallback, useState } from "react";
+import { createSavedView } from "../mutations/createSavedView";
+import { deleteSavedView } from "../mutations/deleteSavedView";
+import { updateSavedView } from "../mutations/updateSavedView";
+import { getSavedViews } from "../queries/getSavedViews";
+
+export function useSavedViews(projectId: number) {
+ const [savedViews, setSavedViews] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchSavedViews = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const views = await getSavedViews(projectId);
+ setSavedViews(views);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch saved views");
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId]);
+
+ const createView = useCallback(
+ async (
+ name: string,
+ cameraPosition: [number, number, number],
+ cameraTarget: [number, number, number],
+ projectionType: ProjectionType,
+ fovYRadian: number,
+ orthographicLeft: number,
+ orthographicRight: number,
+ orthographicBottom: number,
+ orthographicTop: number,
+ ) => {
+ try {
+ setError(null);
+ const newView = await createSavedView({
+ name,
+ cameraPosition,
+ cameraTarget,
+ projectionType,
+ fovYRadian,
+ orthographicLeft,
+ orthographicRight,
+ orthographicBottom,
+ orthographicTop,
+ projectId,
+ });
+ setSavedViews((prev) => [newView, ...prev]);
+ return newView;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create saved view");
+ throw err;
+ }
+ },
+ [projectId],
+ );
+
+ const updateView = useCallback(
+ async (
+ id: number,
+ data: {
+ name?: string;
+ cameraPosition?: [number, number, number];
+ cameraTarget?: [number, number, number];
+ projectionType?: ProjectionType;
+ fovYRadian?: number;
+ orthographicLeft?: number;
+ orthographicRight?: number;
+ orthographicBottom?: number;
+ orthographicTop?: number;
+ },
+ ) => {
+ try {
+ setError(null);
+ const updatedView = await updateSavedView(id, data);
+ setSavedViews((prev) => prev.map((view) => (view.id === id ? updatedView : view)));
+ return updatedView;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to update saved view");
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const deleteView = useCallback(async (id: number) => {
+ try {
+ setError(null);
+ await deleteSavedView(id);
+ setSavedViews((prev) => prev.filter((view) => view.id !== id));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete saved view");
+ throw err;
+ }
+ }, []);
+
+ return {
+ savedViews,
+ loading,
+ error,
+ fetchSavedViews,
+ createView,
+ updateView,
+ deleteView,
+ };
+}
diff --git a/studio/features/saved-views/mutations/createSavedView.ts b/studio/features/saved-views/mutations/createSavedView.ts
new file mode 100644
index 00000000..7fb06404
--- /dev/null
+++ b/studio/features/saved-views/mutations/createSavedView.ts
@@ -0,0 +1,31 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+
+type CreateSavedViewData = {
+ name: string;
+ cameraPosition: [number, number, number];
+ cameraTarget: [number, number, number];
+ projectionType: ProjectionType;
+ fovYRadian: number;
+ orthographicLeft: number;
+ orthographicRight: number;
+ orthographicBottom: number;
+ orthographicTop: number;
+ projectId: number;
+};
+
+export async function createSavedView(data: CreateSavedViewData): Promise {
+ const response = await fetch("/api/savedViews", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create saved view: ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/studio/features/saved-views/mutations/deleteSavedView.ts b/studio/features/saved-views/mutations/deleteSavedView.ts
new file mode 100644
index 00000000..c35ee613
--- /dev/null
+++ b/studio/features/saved-views/mutations/deleteSavedView.ts
@@ -0,0 +1,9 @@
+export async function deleteSavedView(id: number): Promise {
+ const response = await fetch(`/api/savedViews/${id}`, {
+ method: "DELETE",
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete saved view: ${response.statusText}`);
+ }
+}
diff --git a/studio/features/saved-views/mutations/updateSavedView.ts b/studio/features/saved-views/mutations/updateSavedView.ts
new file mode 100644
index 00000000..f09841c5
--- /dev/null
+++ b/studio/features/saved-views/mutations/updateSavedView.ts
@@ -0,0 +1,30 @@
+import { ProjectionType } from "@features/bananagl/camera/cameraInterface";
+import { SavedView } from "@features/db/entities/savedView";
+
+type UpdateSavedViewData = {
+ name?: string;
+ cameraPosition?: [number, number, number];
+ cameraTarget?: [number, number, number];
+ projectionType?: ProjectionType;
+ fovYRadian?: number;
+ orthographicLeft?: number;
+ orthographicRight?: number;
+ orthographicBottom?: number;
+ orthographicTop?: number;
+};
+
+export async function updateSavedView(id: number, data: UpdateSavedViewData): Promise {
+ const response = await fetch(`/api/savedViews/${id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update saved view: ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/studio/features/saved-views/queries/getSavedViews.ts b/studio/features/saved-views/queries/getSavedViews.ts
new file mode 100644
index 00000000..32b25fb0
--- /dev/null
+++ b/studio/features/saved-views/queries/getSavedViews.ts
@@ -0,0 +1,11 @@
+import { SavedView } from "@features/db/entities/savedView";
+
+export async function getSavedViews(projectId: number): Promise {
+ const response = await fetch(`/api/savedViews?projectId=${projectId}`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch saved views: ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/studio/features/viewer/components/Viewer.tsx b/studio/features/viewer/components/Viewer.tsx
index b1949546..a9ba4c57 100644
--- a/studio/features/viewer/components/Viewer.tsx
+++ b/studio/features/viewer/components/Viewer.tsx
@@ -7,10 +7,13 @@ import ActiveColumnToolbar from "@features/editor-toolbar/components/ActiveColum
import CameraViewToolbar from "@features/editor-toolbar/components/CameraViewToolbar";
import ColorSchemeToolbar from "@features/editor-toolbar/components/ColorSchemeToolbar";
import ProjectionToolbar from "@features/editor-toolbar/components/ProjectionToolbar";
+import SavedViewsToolbar from "@features/editor-toolbar/components/SavedViewsToolbar";
+import ScreenshotToolbar from "@features/editor-toolbar/components/ScreenshotToolbar";
import SelectionToolbar from "@features/editor-toolbar/components/SelectionToolbar";
import { CanvasWrapper } from "@features/editor/components/Canvas/CanvasWrapper";
import { TooltipOverlay } from "@features/editor/components/Canvas/TooltipOverlay";
+import { useEmbed } from "@features/embeds/hooks/useEmbed";
type ViewerProps = {
embedId: number;
@@ -19,23 +22,29 @@ type ViewerProps = {
export default function Viewer(props: ViewerProps) {
useMetadataModelStyle();
+ // If embedId is present, we are in embed mode
+ const embedMode = typeof props.embedId === "number";
+ const { data: embed } = useEmbed(props.embedId);
+
return (
-
+
-
+
-
+ {!embed?.onlyTooltipInfo && }
+
+
diff --git a/studio/package-lock.json b/studio/package-lock.json
index 65900223..c8b6e84f 100644
--- a/studio/package-lock.json
+++ b/studio/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metacity-studio",
- "version": "1.0.4",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metacity-studio",
- "version": "1.0.4",
+ "version": "1.2.0",
"dependencies": {
"@adobe/react-spectrum": "^3.35.1",
"@auth0/nextjs-auth0": "^3.5.0",
diff --git a/studio/package.json b/studio/package.json
index 6117e44e..a7e30600 100644
--- a/studio/package.json
+++ b/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "metacity-studio",
- "version": "1.0.4",
+ "version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",