From 55e470d2ca18e39f5e9bbc19c807305fd9fe4b3f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 13 May 2026 15:09:05 -0700 Subject: [PATCH] pagescene: drop view-only mutation gates + rename selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitors can now fully interact with someone else's scene — move objects, add shapes, edit colors, etc. The previous strict view-only mode is gone. Save remains the only auth/ownership-gated action; its rewiring lands in a follow-up. Renames computeIsViewOnly to computeIsVisitingOthersScene to reflect the remaining role (it only drives the PreviewBox today; Save-copy label and Reset button consume it next). Drops getIsViewOnly()/useIsViewOnly() early-returns from engine actions, drag-drop, gizmo attach, keybinds, and the Outliner row controls. Strips !isViewOnly wrappers from chrome in Stage3DBody so the prompt box, controls, panel, and model selector show for everyone. Co-Authored-By: Claude Opus 4.7 --- .../libs/components/pagescene/src/index.ts | 6 +- .../pagescene/src/lib/DragAndDrop/DndAsset.ts | 3 +- .../pagescene/src/lib/PageSceneStore.ts | 18 +++-- .../pagescene/src/lib/Stage3DBody.tsx | 42 ++++++------ .../pagescene/src/lib/actions/addCharacter.ts | 2 - .../pagescene/src/lib/actions/addObject.ts | 2 - .../pagescene/src/lib/actions/addShape.ts | 2 - .../src/lib/actions/setObjectColor.ts | 5 -- .../src/lib/actions/toggleObjectLock.ts | 2 - .../src/lib/actions/toggleObjectVisibility.ts | 2 - .../src/lib/actions/transformObject.ts | 4 -- .../lib/comps/AnonHintChip/AnonHintChip.tsx | 14 ++-- .../src/lib/comps/Outliner/Outliner.tsx | 67 +++++++++---------- .../src/lib/comps/PreviewBox/PreviewBox.tsx | 15 +++-- .../pagescene/src/lib/engine/editor.ts | 3 +- .../src/lib/engine/editor/GizmoController.ts | 2 - .../src/lib/engine/keybinds_controls.ts | 5 -- 17 files changed, 85 insertions(+), 109 deletions(-) diff --git a/frontend/libs/components/pagescene/src/index.ts b/frontend/libs/components/pagescene/src/index.ts index ae56930e8b..3efaa87a52 100644 --- a/frontend/libs/components/pagescene/src/index.ts +++ b/frontend/libs/components/pagescene/src/index.ts @@ -15,7 +15,11 @@ export type { } from "./lib/adapter"; // Store -export { usePageSceneStore, useIsViewOnly, getIsViewOnly } from "./lib/PageSceneStore"; +export { + usePageSceneStore, + useIsVisitingOthersScene, + getIsVisitingOthersScene, +} from "./lib/PageSceneStore"; export type { Camera, FocalLengthDragging, diff --git a/frontend/libs/components/pagescene/src/lib/DragAndDrop/DndAsset.ts b/frontend/libs/components/pagescene/src/lib/DragAndDrop/DndAsset.ts index af4ff139c2..dd39a6304c 100644 --- a/frontend/libs/components/pagescene/src/lib/DragAndDrop/DndAsset.ts +++ b/frontend/libs/components/pagescene/src/lib/DragAndDrop/DndAsset.ts @@ -1,6 +1,6 @@ import React from "react"; import { MediaItem } from "../models"; -import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore"; +import { usePageSceneStore } from "../PageSceneStore"; import { AssetType } from "../enums"; import type Editor from "../engine/editor"; import { pickDropPosition } from "../engine/pickDropPosition"; @@ -37,7 +37,6 @@ class DndAsset { item: MediaItem, editor: Editor | null, ) { - if (getIsViewOnly()) return; if (event.button === 0) { this.editor = editor; const store = usePageSceneStore.getState(); diff --git a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts index 288b38b265..00a59e0ae2 100644 --- a/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts +++ b/frontend/libs/components/pagescene/src/lib/PageSceneStore.ts @@ -495,15 +495,21 @@ export const usePageSceneStore = create((set, get) => ({ setCamViewCanvasEl: (el) => set({ camViewCanvasEl: el }), })); -// View-only mode: visitor is looking at someone else's scene (owner is +// True when the visitor is looking at someone else's scene (owner is // known and isn't them). A blank scene has no owner so this returns // false; the scene owner viewing their own scene returns false too. -const computeIsViewOnly = (s: PageSceneState): boolean => +// +// This no longer gates mutations — visitors can fully interact with +// the scene. It only drives ownership-aware UI: the primary Save +// button switches to "Save copy" (forks the scene to the visitor's +// account on confirm) and the PreviewBox shows the author's +// generation result. +const computeIsVisitingOthersScene = (s: PageSceneState): boolean => s.sceneMeta.ownerToken !== undefined && s.sceneMeta.ownerToken !== s.currentUserToken; -export const useIsViewOnly = (): boolean => - usePageSceneStore(computeIsViewOnly); +export const useIsVisitingOthersScene = (): boolean => + usePageSceneStore(computeIsVisitingOthersScene); -export const getIsViewOnly = (): boolean => - computeIsViewOnly(usePageSceneStore.getState()); +export const getIsVisitingOthersScene = (): boolean => + computeIsVisitingOthersScene(usePageSceneStore.getState()); diff --git a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx index d2d448059c..64bc0cfcbe 100644 --- a/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx +++ b/frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx @@ -64,7 +64,7 @@ import { useViewportSize } from "./hooks/useViewportSize"; import { GridVisibleChangedEvent } from "./engine/events/EngineEvent"; import { pickDropPosition } from "./engine/pickDropPosition"; import { AssetType, CameraAspectRatio } from "./enums"; -import { usePageSceneStore, useIsViewOnly } from "./PageSceneStore"; +import { usePageSceneStore, useIsVisitingOthersScene } from "./PageSceneStore"; import type { MediaItem } from "./models/assets"; const PAGE_ID: ModelPage = ModelPage.Stage3D; @@ -107,7 +107,7 @@ export const Stage3DBody = ({ ); const gridVisible = usePageSceneStore((s) => s.gridVisible); const previewImageUrl = usePageSceneStore((s) => s.sceneMeta.previewImageUrl); - const isViewOnly = useIsViewOnly(); + const isVisitingOthersScene = useIsVisitingOthersScene(); const addCamera = usePageSceneStore((s) => s.addCamera); const updateCamera = usePageSceneStore((s) => s.updateCamera); const deleteCamera = usePageSceneStore((s) => s.deleteCamera); @@ -407,7 +407,7 @@ export const Stage3DBody = ({ return (
- {!isViewOnly && } +
@@ -417,22 +417,20 @@ export const Stage3DBody = ({ - {!isViewOnly && } + - {!isViewOnly && ( -
-
- - -
- -
+
+
+ + +
+
- )} +
- {!isViewOnly && } +
- {isViewOnly && } + {isVisitingOthersScene && } - {!isViewOnly && } + /> - {!isViewOnly && modelSelectorPlacement === "bottom-left" && ( + {modelSelectorPlacement === "bottom-left" && (
- {showCostCalculator && !isViewOnly && ( + {showCostCalculator && ( )} {showHelpMenu && } diff --git a/frontend/libs/components/pagescene/src/lib/actions/addCharacter.ts b/frontend/libs/components/pagescene/src/lib/actions/addCharacter.ts index 58e811966b..0a3b8eecc6 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/addCharacter.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/addCharacter.ts @@ -3,14 +3,12 @@ import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { CreateAction } from "../engine/editor/actions/CreateAction"; import { ObjectAddedEvent } from "../engine/events/EngineEvent"; -import { getIsViewOnly } from "../PageSceneStore"; export async function addCharacter( editor: Editor, item: MediaItem, position?: THREE.Vector3, ): Promise { - if (getIsViewOnly()) return undefined; const obj = await editor.sceneManager?.create( item.media_id, item.name ?? "character", diff --git a/frontend/libs/components/pagescene/src/lib/actions/addObject.ts b/frontend/libs/components/pagescene/src/lib/actions/addObject.ts index 4056424189..48cb5f0209 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/addObject.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/addObject.ts @@ -3,14 +3,12 @@ import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { CreateAction } from "../engine/editor/actions/CreateAction"; import { ObjectAddedEvent } from "../engine/events/EngineEvent"; -import { getIsViewOnly } from "../PageSceneStore"; export async function addObject( editor: Editor, item: MediaItem, position?: THREE.Vector3, ): Promise { - if (getIsViewOnly()) return undefined; const obj = await editor.sceneManager?.create( item.media_id, item.name ?? "object", diff --git a/frontend/libs/components/pagescene/src/lib/actions/addShape.ts b/frontend/libs/components/pagescene/src/lib/actions/addShape.ts index d5f8a9557f..4f5794daba 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/addShape.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/addShape.ts @@ -3,14 +3,12 @@ import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { CreateAction } from "../engine/editor/actions/CreateAction"; import { ObjectAddedEvent } from "../engine/events/EngineEvent"; -import { getIsViewOnly } from "../PageSceneStore"; export async function addShape( editor: Editor, item: MediaItem, position?: THREE.Vector3, ): Promise { - if (getIsViewOnly()) return undefined; const obj = await editor.sceneManager?.create( "Parim", item.media_id, diff --git a/frontend/libs/components/pagescene/src/lib/actions/setObjectColor.ts b/frontend/libs/components/pagescene/src/lib/actions/setObjectColor.ts index c0074a347c..76263612b0 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/setObjectColor.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/setObjectColor.ts @@ -1,6 +1,5 @@ import type Editor from "../engine/editor"; import { ColorAction } from "../engine/editor/actions/ColorAction"; -import { getIsViewOnly } from "../PageSceneStore"; // One-shot apply: use when the change is atomic (e.g. a button click // or programmatic setter). Records a single ColorAction. @@ -15,7 +14,6 @@ export function setObjectColor( uuid: string, color: string, ): void { - if (getIsViewOnly()) return; const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); if (!obj) return; const before = (obj.userData.color as string) ?? "#ffffff"; @@ -38,9 +36,6 @@ export function beginColorSession( editor: Editor, uuid: string, ): ColorSession { - if (getIsViewOnly()) { - return { apply() {}, commit() {} }; - } const startObj = editor.activeScene.scene.getObjectByProperty( "uuid", uuid, diff --git a/frontend/libs/components/pagescene/src/lib/actions/toggleObjectLock.ts b/frontend/libs/components/pagescene/src/lib/actions/toggleObjectLock.ts index 21c49822e2..2f5eb3c660 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/toggleObjectLock.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/toggleObjectLock.ts @@ -1,7 +1,6 @@ import type Editor from "../engine/editor"; import { LockAction } from "../engine/editor/actions/LockAction"; import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent"; -import { getIsViewOnly } from "../PageSceneStore"; // Toggle the locked state of an object: flips userData.locked, runs // the gizmo attach/detach side effect, syncs the outliner row icon in @@ -10,7 +9,6 @@ import { getIsViewOnly } from "../PageSceneStore"; // View callers (Outliner) only need to provide editor + uuid; the // action handles the engine + store + history orchestration. export function toggleObjectLock(editor: Editor, uuid: string): void { - if (getIsViewOnly()) return; const before = editor.selection.isObjectLocked(uuid); editor.bus.emit(new OutlinerItemLockToggledEvent(uuid)); editor.selection.lockUnlockObject(uuid); diff --git a/frontend/libs/components/pagescene/src/lib/actions/toggleObjectVisibility.ts b/frontend/libs/components/pagescene/src/lib/actions/toggleObjectVisibility.ts index 439c8723f7..54591584c5 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/toggleObjectVisibility.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/toggleObjectVisibility.ts @@ -1,7 +1,6 @@ import type Editor from "../engine/editor"; import { VisibilityAction } from "../engine/editor/actions/VisibilityAction"; import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent"; -import { getIsViewOnly } from "../PageSceneStore"; // Toggle the visibility of an object: flips obj.visible + // userData.visible, syncs the outliner row icon in the Zustand store, @@ -11,7 +10,6 @@ import { getIsViewOnly } from "../PageSceneStore"; // helper reads the before-state from the live engine object so the // view doesn't have to plumb it. export function toggleObjectVisibility(editor: Editor, uuid: string): void { - if (getIsViewOnly()) return; const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); if (!obj) return; const before = obj.visible; diff --git a/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts b/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts index ede008d0f4..4cdf449645 100644 --- a/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts +++ b/frontend/libs/components/pagescene/src/lib/actions/transformObject.ts @@ -1,6 +1,5 @@ import type Editor from "../engine/editor"; import { TransformAction } from "../engine/editor/actions/TransformAction"; -import { getIsViewOnly } from "../PageSceneStore"; // A transform "session" — opened on the first edit of a multi-input // editing flow (e.g. Object Panel position/rotation/scale text fields), @@ -25,9 +24,6 @@ export function beginTransformSession( editor: Editor, uuid: string, ): TransformSession { - if (getIsViewOnly()) { - return { commit() {} }; - } const action = new TransformAction(editor, uuid); return { commit() { diff --git a/frontend/libs/components/pagescene/src/lib/comps/AnonHintChip/AnonHintChip.tsx b/frontend/libs/components/pagescene/src/lib/comps/AnonHintChip/AnonHintChip.tsx index 4142d4effe..a86467561a 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/AnonHintChip/AnonHintChip.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/AnonHintChip/AnonHintChip.tsx @@ -2,19 +2,17 @@ import { useContext } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowRightToBracket } from "@fortawesome/pro-solid-svg-icons"; import { EngineContext } from "../../contexts/EngineContext/EngineContext"; -import { usePageSceneStore, useIsViewOnly } from "../../PageSceneStore"; +import { usePageSceneStore } from "../../PageSceneStore"; -// Quiet "you're signed out" affordance shown in the editor playground for -// anonymous visitors. Hidden in view-only mode (the visitor is consuming -// someone else's scene; no upsell pressure needed). The actual per-action -// signup CTAs (Save, Generate, Upload) still fire when the user clicks -// those buttons — this chip just sets expectations up front. +// Quiet "you're signed out" affordance shown in the editor for anonymous +// visitors — playground or someone else's scene alike. The actual +// per-action signup CTAs (Save, Generate, Upload) still fire when the +// user clicks those buttons; this chip just sets expectations up front. export const AnonHintChip = () => { const editor = useContext(EngineContext); const currentUserToken = usePageSceneStore((s) => s.currentUserToken); - const isViewOnly = useIsViewOnly(); - if (currentUserToken || isViewOnly) return null; + if (currentUserToken) return null; const handleClick = () => { editor?.adapter.promptSignup?.("hint"); diff --git a/frontend/libs/components/pagescene/src/lib/comps/Outliner/Outliner.tsx b/frontend/libs/components/pagescene/src/lib/comps/Outliner/Outliner.tsx index d6af441130..5b99dccc20 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/Outliner/Outliner.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/Outliner/Outliner.tsx @@ -15,7 +15,7 @@ import { Transition } from "@headlessui/react"; import { twMerge } from "tailwind-merge"; import { useSignals } from "@preact/signals-react/runtime"; import { EngineContext } from "../../contexts/EngineContext"; -import { OutlinerItem, usePageSceneStore, useIsViewOnly } from "../../PageSceneStore"; +import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore"; import { useViewportSize } from "../../hooks/useViewportSize"; import { CameraAspectRatio } from "../../enums"; import { toggleObjectLock } from "../../actions/toggleObjectLock"; @@ -26,7 +26,6 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => { const isSelected = usePageSceneStore( (s) => s.outlinerSelectedItem?.id === item.id, ); - const isViewOnly = useIsViewOnly(); const editorEngine = useContext(EngineContext); @@ -80,39 +79,37 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
{item.name} - {!isViewOnly && ( -
- - -
- )} +
+ + +
); }; diff --git a/frontend/libs/components/pagescene/src/lib/comps/PreviewBox/PreviewBox.tsx b/frontend/libs/components/pagescene/src/lib/comps/PreviewBox/PreviewBox.tsx index 26f5ba683a..16c7bbf449 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/PreviewBox/PreviewBox.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/PreviewBox/PreviewBox.tsx @@ -2,13 +2,14 @@ import { useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/pro-solid-svg-icons"; -// Small fixed-position card shown next to the camera in view-only mode. -// Renders the author's generation result so a visitor can see what the -// scene was made to produce. Today this is scaffolding only — the URL -// data path (likely a new field returned by adapter.loadScene's response -// payload, propagated into sceneMeta.previewImageUrl) is wired up in a -// follow-up PR. Renders null when no URL is supplied, so it's safe to -// mount unconditionally inside the isViewOnly branch. +// Small fixed-position card shown next to the camera when a visitor is +// looking at someone else's scene. Renders the author's generation +// result so a visitor can see what the scene was made to produce. Today +// this is scaffolding only — the URL data path (likely a new field +// returned by adapter.loadScene's response payload, propagated into +// sceneMeta.previewImageUrl) is wired up in a follow-up PR. Renders +// null when no URL is supplied, so it's safe to mount unconditionally +// inside the isVisitingOthersScene branch. export interface PreviewBoxProps { imageUrl?: string; } diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor.ts b/frontend/libs/components/pagescene/src/lib/engine/editor.ts index eaad438f9e..6b63f90b63 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor.ts @@ -20,7 +20,7 @@ import { TransformAction } from "./editor/actions/TransformAction"; import Stats from "three/examples/jsm/libs/stats.module.js"; import { SparkRenderer } from "@sparkjsdev/spark"; -import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore"; +import { usePageSceneStore } from "../PageSceneStore"; import { ensureAmmoLoaded } from "./ammoLoader"; import { EngineEventBus } from "./events/EngineEventBus"; import { EngineStoreBridge } from "./EngineStoreBridge"; @@ -787,7 +787,6 @@ class Editor { } deleteObject(uuid: string) { - if (getIsViewOnly()) return; const obj = this.activeScene.scene.getObjectByProperty("uuid", uuid); if (obj) this.history.record(new DeleteAction(this, obj)); this.mouse_controls?.clearFKVisuals(); diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor/GizmoController.ts b/frontend/libs/components/pagescene/src/lib/engine/editor/GizmoController.ts index 6e828c94dd..75fe2cc6d4 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor/GizmoController.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor/GizmoController.ts @@ -9,7 +9,6 @@ import type * as THREE from "three"; import { TransformControls } from "../TransformControls.js"; -import { getIsViewOnly } from "../../PageSceneStore"; export type TransformMode = "translate" | "rotate" | "scale"; export type TransformSpace = "world" | "local"; @@ -91,7 +90,6 @@ export class GizmoController { } attach(object: THREE.Object3D) { - if (getIsViewOnly()) return; this.control?.attach(object); } diff --git a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts index dddb25825a..223598c709 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts @@ -21,7 +21,6 @@ import { TransformSpaceChangedEvent, } from "./events/EngineEvent"; import type { PoseMode, TransformSpace } from "../PageSceneStore"; -import { getIsViewOnly } from "../PageSceneStore"; const EDITABLE_INPUT_TYPES = new Set([ "text", @@ -345,7 +344,6 @@ export class MouseControls { this.focus(); return; } else if (event.key === "Backspace" || event.key === "Delete") { - if (getIsViewOnly()) return; if (this.selected) { this.selected.forEach((selected) => { this.deleteObject(selected.uuid); @@ -386,18 +384,15 @@ export class MouseControls { this.deps.bus.emit(new SelectedModeChangedEvent("scale")); return; } else if (event.key === "k") { - if (getIsViewOnly()) return; this.toggleFKMode(); return; } else if (event.key === "b") { - if (getIsViewOnly()) return; // Open asset modal this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true)); return; } if ((event.ctrlKey || event.metaKey) && !this.isProcessing) { - if (getIsViewOnly()) return; const keyLower = event.key.toLowerCase(); if (keyLower === "z" && !event.shiftKey) { // undo