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",