mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00:00
Merge branch 'feature/webapp-pagescene' of github.com:storytold/storyteller-rust into feature/webapp-pagescene
# Conflicts: # frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx # frontend/libs/components/pagescene/src/lib/Stage3DBody.tsx # frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx
This commit is contained in:
commit
f977500553
13 files changed
+392
-88
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
faImage,
|
||||
faVideo,
|
||||
faWandMagicSparkles,
|
||||
faCube,
|
||||
faArrowRight,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
@@ -37,6 +38,15 @@ const APPS: AppCard[] = [
|
||||
iconBg: "bg-purple-500/25 border-purple-400/30",
|
||||
iconColor: "text-purple-300",
|
||||
},
|
||||
{
|
||||
label: "Edit 3D",
|
||||
description: "Compose 3D scenes and render with AI cameras.",
|
||||
href: "/edit-3d",
|
||||
icon: faCube,
|
||||
accent: "from-amber-500/20 to-amber-500/0",
|
||||
iconBg: "bg-amber-500/25 border-amber-400/30",
|
||||
iconColor: "text-amber-300",
|
||||
},
|
||||
{
|
||||
label: "Background Change",
|
||||
description: "Swap or remove backgrounds with AI VFX.",
|
||||
@@ -57,12 +67,12 @@ export function Home() {
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-semibold text-white/85 mb-4">Create</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
|
||||
{APPS.map((app) => (
|
||||
<Link
|
||||
key={app.href}
|
||||
to={app.href}
|
||||
className="bg-ui-controls/50 group relative overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
|
||||
className="bg-ui-controls/50 group relative h-full overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
|
||||
>
|
||||
<div
|
||||
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDesktop, faHouse } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
import {
|
||||
GalleryModal,
|
||||
GalleryDragComponent,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { useSidebar } from "../../components/ui/sidebar";
|
||||
import { useSignupCta } from "../../components/signup-cta-modal";
|
||||
@@ -23,20 +30,31 @@ import { useWebAppPageSceneAdapter } from "./web-adapter";
|
||||
// On entry the sidebar auto-collapses on desktop to give the lib's
|
||||
// 100vw-leaning layout the room it expects.
|
||||
|
||||
// Gate the editor on mobile. The lib's layout is built around a
|
||||
// 100vw-leaning 3D viewport and pointer-precision controls (right-
|
||||
// click drag pan, WASD fly cam, gizmo handles), none of which work
|
||||
// on touch. The conditional is at the top level so that the editor's
|
||||
// heavier hooks (adapter construction, ResizeObserver, engine mount
|
||||
// inside Stage3D) never run on mobile.
|
||||
export default function PageScene() {
|
||||
const { isMobile } = useSidebar();
|
||||
if (isMobile) return <MobileGate />;
|
||||
return <PageSceneEditor />;
|
||||
}
|
||||
|
||||
function PageSceneEditor() {
|
||||
const { sceneToken } = useParams<{ sceneToken?: string }>();
|
||||
const { user } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const { setOpen, isMobile } = useSidebar();
|
||||
const { setOpen } = useSidebar();
|
||||
const { openSignupCta } = useSignupCta();
|
||||
|
||||
const didAutoCollapseRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didAutoCollapseRef.current) return;
|
||||
if (isMobile) return;
|
||||
setOpen(false);
|
||||
didAutoCollapseRef.current = true;
|
||||
}, [isMobile, setOpen]);
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
|
||||
@@ -89,7 +107,46 @@ export default function PageScene() {
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
style={{ transform: "translateZ(0)" }}
|
||||
>
|
||||
<Stage3D adapter={adapter} sceneToken={sceneToken} />
|
||||
<Stage3D
|
||||
adapter={adapter}
|
||||
sceneToken={sceneToken}
|
||||
showCostCalculator={false}
|
||||
showImageTo3DButton={false}
|
||||
showHelpMenu={false}
|
||||
modelSelectorPlacement="prompt-box"
|
||||
/>
|
||||
{/* Controls3D's "My Library" popup item flips the
|
||||
galleryModalVisibleViewMode signal — the modal below subscribes
|
||||
to that signal, and items inside it dispatch onto galleryDnd
|
||||
which Stage3DBody's onImageDrop handler converts into scene
|
||||
adds. Both components portal themselves out of this wrapper. */}
|
||||
<GalleryModal mode="view" />
|
||||
<GalleryDragComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileGate() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-6">
|
||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/25 text-amber-300">
|
||||
<FontAwesomeIcon icon={faDesktop} className="text-xl" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
Edit 3D is desktop-only
|
||||
</h1>
|
||||
<p className="text-sm text-white/60 leading-relaxed">
|
||||
The 3D editor needs a mouse and keyboard for camera flight, gizmo
|
||||
handles, and precision selection. Open this page on a desktop or
|
||||
laptop to start editing.
|
||||
</p>
|
||||
<Link to="/" className="mt-2">
|
||||
<Button variant="primary" icon={faHouse}>
|
||||
Back to home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ module.exports = {
|
||||
red: { ...colors.red, DEFAULT: "#D33242" },
|
||||
blue: { ...colors.blue, DEFAULT: "#2E70FF" },
|
||||
orange: colors.orange,
|
||||
amber: colors.amber,
|
||||
green: { ...colors.green, DEFAULT: "#308752" },
|
||||
emerald: colors.emerald,
|
||||
teal: colors.teal,
|
||||
@@ -166,6 +167,9 @@ module.exports = {
|
||||
divider: "#272727",
|
||||
modal: "#1F1F1F",
|
||||
},
|
||||
"axis-x": "#D33242",
|
||||
"axis-y": "#308752",
|
||||
"axis-z": "#2E70FF",
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -26,6 +26,23 @@ export interface Stage3DProps {
|
||||
/** Called on unmount with the serialized scene JSON. The host
|
||||
* decides where to put it (tab store, localStorage, nowhere). */
|
||||
onSceneSerialized?: (json: string) => void;
|
||||
/** Show the bottom-right "Costs" cost-calculator button. Defaults
|
||||
* to true so existing hosts (Tauri) keep their current chrome;
|
||||
* hosts can opt out (e.g. the webapp suppresses it). */
|
||||
showCostCalculator?: boolean;
|
||||
/** Show the top-bar "Create 3D model from image" magic-wand
|
||||
* shortcut. Defaults to true; hosts without an Image-to-3D
|
||||
* destination (e.g. the webapp) can hide it. */
|
||||
showImageTo3DButton?: boolean;
|
||||
/** Show the bottom-right help menu button. Defaults to true; hosts
|
||||
* whose help menu wiring isn't yet plumbed (e.g. the webapp) can
|
||||
* hide it. */
|
||||
showHelpMenu?: boolean;
|
||||
/** Where to render the model picker. `"bottom-left"` (default) keeps
|
||||
* the floating ClassyModelSelector that Tauri uses; `"prompt-box"`
|
||||
* hides it and renders a compact selector inside the prompt-box
|
||||
* toolbar instead, matching the webapp's other prompt-box chrome. */
|
||||
modelSelectorPlacement?: "bottom-left" | "prompt-box";
|
||||
}
|
||||
|
||||
export const Stage3D = ({
|
||||
@@ -33,6 +50,10 @@ export const Stage3D = ({
|
||||
sceneToken,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
showCostCalculator = true,
|
||||
showImageTo3DButton = true,
|
||||
showHelpMenu = true,
|
||||
modelSelectorPlacement = "bottom-left",
|
||||
}: Stage3DProps) => {
|
||||
// Engine's remountEngine() gate reads is3DPageMounted. With Stage3D
|
||||
// mounting only when the host's tab/route puts us on screen, the
|
||||
@@ -52,7 +73,12 @@ export const Stage3D = ({
|
||||
cacheJsonString={cacheJsonString}
|
||||
onSceneSerialized={onSceneSerialized}
|
||||
>
|
||||
<Stage3DBody />
|
||||
<Stage3DBody
|
||||
showCostCalculator={showCostCalculator}
|
||||
showImageTo3DButton={showImageTo3DButton}
|
||||
showHelpMenu={showHelpMenu}
|
||||
modelSelectorPlacement={modelSelectorPlacement}
|
||||
/>
|
||||
<DragComponent />
|
||||
<PrecisionSelector />
|
||||
<EditorLoadingBar />
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
// exact same 3D editor UX. Only the platform-specific PageSceneAdapter
|
||||
// implementation differs between hosts.
|
||||
|
||||
import React, { useContext, useEffect, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
GalleryItem,
|
||||
onImageDrop,
|
||||
@@ -16,10 +22,14 @@ import {
|
||||
import {
|
||||
STAGE_3D_PAGE_MODEL_LIST,
|
||||
ModelPage,
|
||||
defaultModelForPage,
|
||||
useClassyModelSelectorStore,
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
ClassyModelSelector,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import type { ImageModel } from "@storyteller/model-list";
|
||||
import type { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
@@ -59,7 +69,27 @@ import type { MediaItem } from "./models/assets";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Stage3D;
|
||||
|
||||
export const Stage3DBody = () => {
|
||||
export interface Stage3DBodyProps {
|
||||
/** Show the bottom-right "Costs" cost-calculator button. */
|
||||
showCostCalculator?: boolean;
|
||||
/** Show the top-bar "Create 3D model from image" magic-wand button. */
|
||||
showImageTo3DButton?: boolean;
|
||||
/** Show the bottom-right help menu button. */
|
||||
showHelpMenu?: boolean;
|
||||
/** Where to render the model picker. `"bottom-left"` (default) keeps
|
||||
* the existing ClassyModelSelector floating in the editor corner —
|
||||
* used by Tauri. `"prompt-box"` hides the corner selector and
|
||||
* renders a compact popover inside the prompt-box toolbar instead,
|
||||
* matching the webapp's other prompt boxes. */
|
||||
modelSelectorPlacement?: "bottom-left" | "prompt-box";
|
||||
}
|
||||
|
||||
export const Stage3DBody = ({
|
||||
showCostCalculator = true,
|
||||
showImageTo3DButton = true,
|
||||
showHelpMenu = true,
|
||||
modelSelectorPlacement = "bottom-left",
|
||||
}: Stage3DBodyProps = {}) => {
|
||||
const camAspect = usePageSceneStore((s) => s.cameraAspectRatio);
|
||||
const outlinerShowing = usePageSceneStore((s) => s.outlinerShowing);
|
||||
const editorLoader = usePageSceneStore((s) => s.editorLoader);
|
||||
@@ -101,6 +131,63 @@ export const Stage3DBody = () => {
|
||||
const selectedProvider: GenerationProvider | undefined =
|
||||
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
|
||||
|
||||
// Inline (prompt-box) model selector. Built here rather than in
|
||||
// PromptBox3D so the promptbox lib doesn't take a new dep on
|
||||
// model-selector; the selector is just plumbed in as a ReactNode slot.
|
||||
const setSelectedModel = useClassyModelSelectorStore(
|
||||
(s) => s.setSelectedModel,
|
||||
);
|
||||
|
||||
// Seed the default model on mount when we're the only model picker
|
||||
// on the page. ClassyModelSelector does this itself on mount, but in
|
||||
// the prompt-box placement we don't render it — so without this
|
||||
// effect the store stays empty and the trigger has no icon until the
|
||||
// user opens the popover and picks a model manually.
|
||||
useEffect(() => {
|
||||
if (modelSelectorPlacement !== "prompt-box") return;
|
||||
if (selectedImageModel) return;
|
||||
const models = STAGE_3D_PAGE_MODEL_LIST.map((i) => i.model).filter(
|
||||
(m): m is NonNullable<typeof m> => m !== undefined,
|
||||
);
|
||||
const def = defaultModelForPage(models, PAGE_ID);
|
||||
if (def) setSelectedModel(PAGE_ID, def);
|
||||
}, [modelSelectorPlacement, selectedImageModel, setSelectedModel]);
|
||||
|
||||
const inlineModelItems: PopoverItem[] = useMemo(
|
||||
() =>
|
||||
STAGE_3D_PAGE_MODEL_LIST.map((item) => ({
|
||||
...item,
|
||||
selected: item.model === selectedImageModel,
|
||||
})),
|
||||
[selectedImageModel],
|
||||
);
|
||||
const handleInlineModelSelect = useCallback(
|
||||
(item: PopoverItem) => {
|
||||
if (item.model) setSelectedModel(PAGE_ID, item.model);
|
||||
},
|
||||
[setSelectedModel],
|
||||
);
|
||||
const selectedModelIcon = useMemo(
|
||||
() =>
|
||||
STAGE_3D_PAGE_MODEL_LIST.find((i) => i.model === selectedImageModel)
|
||||
?.icon,
|
||||
[selectedImageModel],
|
||||
);
|
||||
const inlineModelSelector =
|
||||
modelSelectorPlacement === "prompt-box" ? (
|
||||
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
|
||||
<PopoverMenu
|
||||
items={inlineModelItems}
|
||||
onSelect={handleInlineModelSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[260px]"
|
||||
showIconsInList
|
||||
triggerIcon={selectedModelIcon}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
|
||||
const imageCredits = useCostBreakdownModalStore(
|
||||
(s) => s.estimatedCreditsByPage[PAGE_ID],
|
||||
);
|
||||
@@ -124,10 +211,7 @@ export const Stage3DBody = () => {
|
||||
if (viewport.width > 2000) return scaleHeight;
|
||||
return scaleHeight * 0.78;
|
||||
}
|
||||
if (
|
||||
camAspect === CameraAspectRatio.SQUARE_1_1 &&
|
||||
viewport.width < 2000
|
||||
) {
|
||||
if (camAspect === CameraAspectRatio.SQUARE_1_1 && viewport.width < 2000) {
|
||||
return scaleHeight * 0.85;
|
||||
}
|
||||
return scaleHeight;
|
||||
@@ -304,8 +388,7 @@ export const Stage3DBody = () => {
|
||||
title: item.label || "Image Plane",
|
||||
mediaToken: item.id,
|
||||
progressCallback: (state) => {
|
||||
if (state.status)
|
||||
console.log("Upload status:", state.status);
|
||||
if (state.status) console.log("Upload status:", state.status);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -327,10 +410,7 @@ export const Stage3DBody = () => {
|
||||
{!isViewOnly && <OnboardingHelper />}
|
||||
|
||||
<div className="relative flex h-full w-full">
|
||||
<div
|
||||
id="engine-n-panels-wrapper"
|
||||
className="flex h-full w-full"
|
||||
>
|
||||
<div id="engine-n-panels-wrapper" className="flex h-full w-full">
|
||||
<div className="relative w-full overflow-hidden bg-transparent">
|
||||
<SceneContainer>
|
||||
<EditorCanvas />
|
||||
@@ -346,7 +426,7 @@ export const Stage3DBody = () => {
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ControlsTopButtons />
|
||||
<Controls3D />
|
||||
<Controls3D showImageTo3DButton={showImageTo3DButton} />
|
||||
<div className="flex items-start justify-end gap-2 pr-2 pt-2">
|
||||
<AnonHintChip />
|
||||
</div>
|
||||
@@ -355,8 +435,7 @@ export const Stage3DBody = () => {
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0"
|
||||
style={{ width: viewport.width }}
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div
|
||||
@@ -406,6 +485,7 @@ export const Stage3DBody = () => {
|
||||
editor.positive_prompt = prompt;
|
||||
}}
|
||||
snapshotCurrentFrame={editor?.snapShotOfCurrentFrame.bind(editor)}
|
||||
modelSelector={inlineModelSelector}
|
||||
onBeforeSubmit={() => {
|
||||
const currentUserToken =
|
||||
usePageSceneStore.getState().currentUserToken;
|
||||
@@ -424,7 +504,7 @@ export const Stage3DBody = () => {
|
||||
message={editorLoader.message}
|
||||
/>
|
||||
|
||||
{!isViewOnly && (
|
||||
{!isViewOnly && modelSelectorPlacement === "bottom-left" && (
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
|
||||
<ClassyModelSelector
|
||||
items={STAGE_3D_PAGE_MODEL_LIST}
|
||||
@@ -437,10 +517,14 @@ export const Stage3DBody = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
{!isViewOnly && <CostCalculatorButton modelPage={PAGE_ID} />}
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
{(showCostCalculator || showHelpMenu) && (
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
{showCostCalculator && !isViewOnly && (
|
||||
<CostCalculatorButton modelPage={PAGE_ID} />
|
||||
)}
|
||||
{showHelpMenu && <HelpMenuButton />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-17
@@ -25,10 +25,7 @@ import { objectMismatch } from "./utils/objectMismatch";
|
||||
import { XYZ } from "../../datastructures/common";
|
||||
import { useViewportSize } from "../../hooks/useViewportSize";
|
||||
import { DraggablePrecisionMutator } from "./DraggablePrecisionMutator";
|
||||
import {
|
||||
beginColorSession,
|
||||
ColorSession,
|
||||
} from "../../actions/setObjectColor";
|
||||
import { beginColorSession, ColorSession } from "../../actions/setObjectColor";
|
||||
import {
|
||||
beginTransformSession,
|
||||
TransformSession,
|
||||
@@ -161,7 +158,9 @@ export const ControlPanelSceneObject = () => {
|
||||
setLocalRotation(engineToLocal(vectors.rotation));
|
||||
setLocalScale(engineToLocal(vectors.scale));
|
||||
|
||||
setLocked(editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || ""));
|
||||
setLocked(
|
||||
editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || ""),
|
||||
);
|
||||
setColor(editorEngine?.selected?.userData.color);
|
||||
// No cleanup function — uuid-change commit lives in the body
|
||||
// above. Unmount commit is handled by the separate effect below.
|
||||
@@ -327,7 +326,7 @@ export const ControlPanelSceneObject = () => {
|
||||
className={"flex flex-col gap-2 overflow-y-auto"}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5>Color</h5>
|
||||
<p className="font-semibold text-sm">Color</p>
|
||||
<input
|
||||
className="h-0 w-0 cursor-pointer opacity-0"
|
||||
id={colorInputId}
|
||||
@@ -341,10 +340,7 @@ export const ControlPanelSceneObject = () => {
|
||||
// close.
|
||||
const uuid = editorEngine?.selected?.uuid;
|
||||
if (uuid && editorEngine) {
|
||||
colorSessionRef.current = beginColorSession(
|
||||
editorEngine,
|
||||
uuid,
|
||||
);
|
||||
colorSessionRef.current = beginColorSession(editorEngine, uuid);
|
||||
}
|
||||
}}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -361,15 +357,15 @@ export const ControlPanelSceneObject = () => {
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="cursor-pointer p-3.5"
|
||||
className="cursor-pointer p-3.5 w-full"
|
||||
htmlFor={colorInputId}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
></Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5>Location</h5>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-semibold text-sm">Location</p>
|
||||
<InputVector
|
||||
x={localPosition.x.toString()}
|
||||
y={localPosition.y.toString()}
|
||||
@@ -381,8 +377,8 @@ export const ControlPanelSceneObject = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5>Rotation</h5>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-semibold text-sm">Rotation</p>
|
||||
<InputVector
|
||||
x={localRotation.x.toString()}
|
||||
y={localRotation.y.toString()}
|
||||
@@ -395,9 +391,9 @@ export const ControlPanelSceneObject = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex flex-col gap-1">
|
||||
<div className="mb-1 flex flex-col gap-2">
|
||||
<DraggablePrecisionMutator onChange={handleUniformScaleChange}>
|
||||
<h5>Scale</h5>
|
||||
<p className="font-semibold text-sm">Scale</p>
|
||||
</DraggablePrecisionMutator>
|
||||
<InputVector
|
||||
x={localScale.x.toString()}
|
||||
|
||||
@@ -30,7 +30,15 @@ import {
|
||||
} from "../../PageSceneStore";
|
||||
import { AssetModal } from "../AssetMenu";
|
||||
|
||||
export const Controls3D = () => {
|
||||
export interface Controls3DProps {
|
||||
/** Show the magic-wand "Create 3D model from image" shortcut next
|
||||
* to the add-asset button. */
|
||||
showImageTo3DButton?: boolean;
|
||||
}
|
||||
|
||||
export const Controls3D = ({
|
||||
showImageTo3DButton = true,
|
||||
}: Controls3DProps = {}) => {
|
||||
useSignals();
|
||||
const editor = useContext(EngineContext);
|
||||
const {
|
||||
@@ -250,19 +258,21 @@ export const Controls3D = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
content="Create 3D model from image"
|
||||
position="bottom"
|
||||
delay={300}
|
||||
closeOnClick
|
||||
>
|
||||
<Button
|
||||
icon={faMagicWandSparkles}
|
||||
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
|
||||
variant="secondary"
|
||||
onClick={handleOpenCreate3dModal}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showImageTo3DButton && (
|
||||
<Tooltip
|
||||
content="Create 3D model from image"
|
||||
position="bottom"
|
||||
delay={300}
|
||||
closeOnClick
|
||||
>
|
||||
<Button
|
||||
icon={faMagicWandSparkles}
|
||||
className="text-md h-9 w-9 rounded-[10px] bg-white/15 transition-colors hover:bg-white/25"
|
||||
variant="secondary"
|
||||
onClick={handleOpenCreate3dModal}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="opacity-20">|</span>
|
||||
|
||||
@@ -37,20 +37,71 @@ export const KeyGroup = (props: { children: React.ReactNode }) => (
|
||||
<div className="flex gap-1">{props.children}</div>
|
||||
);
|
||||
|
||||
// Inline SVG instead of /resources/icons/mouse_*.png. The PNGs only
|
||||
// ship with the Tauri host's public dir, so the webapp host showed a
|
||||
// broken-image placeholder. Inlining keeps the lib self-contained and
|
||||
// renders identically across both apps.
|
||||
export const Mouse = (props: {
|
||||
button: "left" | "middle" | "right";
|
||||
label?: string;
|
||||
}) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
alt={`${props.button} mouse`}
|
||||
src={`/resources/icons/mouse_${props.button === "left" ? "lmb" : props.button === "middle" ? "mmb" : "rmb"}.png`}
|
||||
className="h-7 w-auto object-contain"
|
||||
/>
|
||||
<MouseIcon active={props.button} />
|
||||
<span className="text-sm font-normal opacity-60">{props.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MouseIcon = ({ active }: { active: "left" | "middle" | "right" }) => (
|
||||
<svg
|
||||
viewBox="0 0 20 28"
|
||||
aria-hidden
|
||||
className="h-7 w-auto text-white"
|
||||
>
|
||||
<rect
|
||||
x="1.25"
|
||||
y="1.25"
|
||||
width="17.5"
|
||||
height="25.5"
|
||||
rx="8.75"
|
||||
ry="11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.85"
|
||||
/>
|
||||
{active === "left" && (
|
||||
<path
|
||||
d="M9 1.75 V12 H1.5 V10 A8.5 8.5 0 0 1 9 1.75 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
{active === "right" && (
|
||||
<path
|
||||
d="M11 1.75 V12 H18.5 V10 A8.5 8.5 0 0 0 11 1.75 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x="9"
|
||||
y="4"
|
||||
width="2"
|
||||
height="5"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
opacity={active === "middle" ? "1" : "0.5"}
|
||||
/>
|
||||
<line
|
||||
x1="1.5"
|
||||
y1="12"
|
||||
x2="18.5"
|
||||
y2="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Plus = () => <div className="text-xl font-medium">+</div>;
|
||||
|
||||
const ShortcutsView = () => (
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePageSceneStore } from "../../PageSceneStore";
|
||||
|
||||
// Portaled to <body> so the chip stays anchored to the page (using the
|
||||
// document-coord pageX/pageY values stored in dragPosition) regardless
|
||||
// of any positioned/`transform`ed ancestor the host wraps Stage3D in.
|
||||
// Without the portal, hosts whose Stage3D wrapper is a containing
|
||||
// block (e.g. the webapp's wrapper uses `transform: translateZ(0)` to
|
||||
// scope the lib's fixed overlays) push the chip away from the cursor
|
||||
// by the wrapper's offset. Tauri has no such wrapper so the portal is
|
||||
// a no-op for it.
|
||||
export const DragComponent = () => {
|
||||
const dragItem = usePageSceneStore((s) => s.dragItem);
|
||||
const dragPosition = usePageSceneStore((s) => s.dragPosition);
|
||||
@@ -7,15 +16,18 @@ export const DragComponent = () => {
|
||||
if (!dragItem) {
|
||||
return null;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const { currX, currY } = dragPosition;
|
||||
|
||||
const thumbnail = dragItem.thumbnail
|
||||
? dragItem.thumbnail
|
||||
: `/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className="absolute rounded-lg"
|
||||
className="pointer-events-none absolute rounded-lg"
|
||||
style={{
|
||||
width: 91,
|
||||
height: 114,
|
||||
@@ -35,6 +47,7 @@ export const DragComponent = () => {
|
||||
<div className="w-full truncate rounded-b-lg bg-ui-controls px-2 py-1 text-center text-[12px]">
|
||||
{dragItem.name || dragItem.media_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -164,9 +164,8 @@ class Editor {
|
||||
// strictly one-way: UI toggles emit GridVisibleChangedEvent on the
|
||||
// bus → bridge updates the store + this subscriber updates the
|
||||
// THREE.js gridHelper. No "store→engine" subscription anywhere.
|
||||
this.gridSubscription = this.bus.subscribe(
|
||||
GridVisibleChangedEvent,
|
||||
(e) => this.activeScene?.applyGridVisibility(e.visible),
|
||||
this.gridSubscription = this.bus.subscribe(GridVisibleChangedEvent, (e) =>
|
||||
this.activeScene?.applyGridVisibility(e.visible),
|
||||
);
|
||||
|
||||
// PostProcessingPipeline must exist before Scene because Scene's
|
||||
@@ -191,8 +190,7 @@ class Editor {
|
||||
hideObjectPanel: () =>
|
||||
this.bus.emit(new InspectorPanelChangedEvent(null)),
|
||||
getCameras: () => usePageSceneStore.getState().cameras,
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
getSelectedCameraId: () => usePageSceneStore.getState().selectedCameraId,
|
||||
bus: this.bus,
|
||||
});
|
||||
this.selection = new SelectionBridge({
|
||||
@@ -203,8 +201,7 @@ class Editor {
|
||||
setObjectLocked: (uuid, locked) =>
|
||||
this.utils.setObjectLocked(uuid, locked),
|
||||
isObjectLocked: (uuid) => this.utils.isObjectLocked(uuid),
|
||||
removeTransformControls: () =>
|
||||
this.utils.removeTransformControls(false),
|
||||
removeTransformControls: () => this.utils.removeTransformControls(false),
|
||||
attachGizmoToCurrentSelection: () => {
|
||||
this.gizmo.addToScene(this.activeScene.scene);
|
||||
const selected = this.sceneManager?.selected_objects?.[0];
|
||||
@@ -232,8 +229,7 @@ class Editor {
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
fetchAsset: (url) => this.adapter.fetchAsset(url),
|
||||
getMediaUrlByToken: (token) =>
|
||||
this.adapter.getMediaUrlByToken(token),
|
||||
getMediaUrlByToken: (token) => this.adapter.getMediaUrlByToken(token),
|
||||
},
|
||||
);
|
||||
this.activeScene.initialize();
|
||||
@@ -280,8 +276,7 @@ class Editor {
|
||||
saveSceneState: (args) => this.adapter.saveScene(args),
|
||||
loadSceneState: (token) => this.adapter.loadScene(token),
|
||||
getCameras: () => usePageSceneStore.getState().cameras,
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
getSelectedCameraId: () => usePageSceneStore.getState().selectedCameraId,
|
||||
bus: this.bus,
|
||||
});
|
||||
this.viewport = new ViewportController({
|
||||
@@ -291,6 +286,13 @@ class Editor {
|
||||
getRenderAspectRatio: () =>
|
||||
this.cameraController.getRenderDimensions().aspectRatio,
|
||||
resizePostProcessing: (w, h) => this.postProcessing.resize(w, h),
|
||||
renderScene: () => {
|
||||
// Fire-and-forget: renderScene is async only for optional
|
||||
// post-processing branches; the underlying Three.js render is
|
||||
// synchronous and that's all the viewport needs to avoid a
|
||||
// blank frame after setSize.
|
||||
void this.renderScene();
|
||||
},
|
||||
});
|
||||
|
||||
// Action classes under engine/editor/actions/ encapsulate their own
|
||||
@@ -581,7 +583,6 @@ class Editor {
|
||||
document.body.appendChild(this.stats.dom);
|
||||
}
|
||||
|
||||
|
||||
// Captures the scene without the grid
|
||||
public snapShotOfCurrentFrame(shouldDownload: boolean = true) {
|
||||
const camera = this.cameraController.camera;
|
||||
@@ -791,7 +792,8 @@ class Editor {
|
||||
|
||||
// Render the scene to the camera, this is called in the update.
|
||||
async renderScene() {
|
||||
const { render_camera, render_width, render_height } = this.cameraController;
|
||||
const { render_camera, render_width, render_height } =
|
||||
this.cameraController;
|
||||
if (
|
||||
this.postProcessing.composer != null &&
|
||||
this.rawRenderer &&
|
||||
@@ -811,12 +813,14 @@ class Editor {
|
||||
|
||||
if (this.viewport.container) {
|
||||
if (
|
||||
this.viewport.container.clientWidth + this.viewport.container.clientHeight !==
|
||||
this.viewport.container.clientWidth +
|
||||
this.viewport.container.clientHeight !==
|
||||
this.viewport.lastCanvasSize
|
||||
) {
|
||||
this.viewport.onWindowResize();
|
||||
this.viewport.lastCanvasSize =
|
||||
this.viewport.container.clientWidth + this.viewport.container.clientHeight;
|
||||
this.viewport.container.clientWidth +
|
||||
this.viewport.container.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export type ViewportEngineRefs = {
|
||||
getRenderer: () => THREE.WebGLRenderer | undefined;
|
||||
getRenderAspectRatio: () => number;
|
||||
resizePostProcessing: (width: number, height: number) => void;
|
||||
// Called synchronously after a setSize cascade so the canvas gets a
|
||||
// fresh draw before the next browser paint. Three.js's setSize clears
|
||||
// the canvas backing buffer; without an immediate redraw the browser
|
||||
// can paint an empty canvas for one frame, which shows up as flicker
|
||||
// when the container is animating (e.g. sidebar collapse).
|
||||
renderScene: () => void;
|
||||
};
|
||||
|
||||
export class ViewportController {
|
||||
@@ -65,13 +71,17 @@ export class ViewportController {
|
||||
}
|
||||
|
||||
// Observed resizes from the ResizeObserver use a narrower update —
|
||||
// camera + renderer.setSize + setPixelRatio only — preserved from the
|
||||
// original editor.ts behaviour. Post-processing is intentionally
|
||||
// skipped here, matching the pre-refactor code.
|
||||
// camera + renderer.setSize only — preserved from the original
|
||||
// editor.ts behaviour. Post-processing is intentionally skipped here,
|
||||
// matching the pre-refactor code. setPixelRatio runs once at setup
|
||||
// since DPR doesn't change for the lifetime of the page short of the
|
||||
// user dragging the window across monitors with different scaling.
|
||||
setupResizeObserver() {
|
||||
this.containerMayReset();
|
||||
if (!this.container) return;
|
||||
|
||||
this.engine.getRenderer()?.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
this.observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
@@ -81,8 +91,11 @@ export class ViewportController {
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
const renderer = this.engine.getRenderer();
|
||||
renderer?.setSize(width, height);
|
||||
renderer?.setPixelRatio(window.devicePixelRatio);
|
||||
if (!renderer) continue;
|
||||
renderer.setSize(width, height);
|
||||
// setSize wipes the canvas backing buffer. Redraw synchronously
|
||||
// so the next browser paint isn't an empty frame.
|
||||
this.engine.renderScene();
|
||||
}
|
||||
});
|
||||
this.observer.observe(this.container);
|
||||
|
||||
@@ -47,6 +47,16 @@ const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
// Hold-Shift boost matches the help-dialog "Speed Boost" shortcut.
|
||||
// 3x is what the legacy MouseControls.onkeydown path used (and which
|
||||
// never got wired up to a listener in the React refactor), so we
|
||||
// preserve that feel here.
|
||||
const BASE_MOVEMENT_SPEED = 1.15;
|
||||
const BOOSTED_MOVEMENT_SPEED = BASE_MOVEMENT_SPEED * 3;
|
||||
|
||||
const isShiftKey = (code: string) =>
|
||||
code === "ShiftLeft" || code === "ShiftRight";
|
||||
|
||||
export const useFreeCam = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
editor: Editor | null,
|
||||
@@ -74,6 +84,9 @@ export const useFreeCam = (
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
if (isShiftKey(e.code)) {
|
||||
state.movementSpeed = BOOSTED_MOVEMENT_SPEED;
|
||||
}
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 1;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
@@ -83,6 +96,9 @@ export const useFreeCam = (
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (usePageSceneStore.getState().isPromptBoxFocused) return;
|
||||
if (isEventFromEditableElement(e)) return;
|
||||
if (isShiftKey(e.code)) {
|
||||
state.movementSpeed = BASE_MOVEMENT_SPEED;
|
||||
}
|
||||
const moveSlot = moveSlotForKeyCode(e.code);
|
||||
if (moveSlot) state.moveKeys[moveSlot] = 0;
|
||||
const rotateSlot = rotateSlotForKeyCode(e.code);
|
||||
@@ -138,6 +154,15 @@ export const useFreeCam = (
|
||||
|
||||
const onContextMenu = (e: Event) => e.preventDefault();
|
||||
|
||||
// Reset on blur — if the user holds Shift, switches tabs/windows,
|
||||
// then releases, the keyup never reaches us and the boost would
|
||||
// otherwise stay stuck on. Same goes for any held movement keys.
|
||||
const onBlur = () => {
|
||||
state.movementSpeed = BASE_MOVEMENT_SPEED;
|
||||
state.moveKeys = emptyMoveKeys();
|
||||
state.rotateKeys = emptyRotateKeys();
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", onPointerDown);
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
@@ -145,9 +170,11 @@ export const useFreeCam = (
|
||||
canvas.addEventListener("contextmenu", onContextMenu);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", onBlur);
|
||||
|
||||
return () => {
|
||||
state.enabled = false;
|
||||
state.movementSpeed = BASE_MOVEMENT_SPEED;
|
||||
state.moveKeys = emptyMoveKeys();
|
||||
state.rotateKeys = emptyRotateKeys();
|
||||
state.velocity.set(0, 0, 0);
|
||||
@@ -159,6 +186,7 @@ export const useFreeCam = (
|
||||
canvas.removeEventListener("contextmenu", onContextMenu);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, [canvas, editor]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
import { IsDesktopApp } from "@storyteller/tauri-utils";
|
||||
import {
|
||||
faCamera,
|
||||
@@ -74,6 +74,12 @@ interface PromptBox3DProps {
|
||||
} | null)
|
||||
| undefined;
|
||||
credits?: number | null;
|
||||
/** Optional model-picker slot rendered at the start of the toolbar
|
||||
* (left of the aspect-ratio picker). Tauri leaves this unset and
|
||||
* renders ClassyModelSelector outside the prompt box; the webapp
|
||||
* passes a compact selector here so the chrome matches its other
|
||||
* prompt boxes. */
|
||||
modelSelector?: ReactNode;
|
||||
// Optional pre-submit gate. Returns false to abort the generation
|
||||
// (e.g. host wants to open a signup modal for anon visitors).
|
||||
onBeforeSubmit?: () => boolean;
|
||||
@@ -103,6 +109,7 @@ export const PromptBox3D = ({
|
||||
selectedProvider,
|
||||
snapshotCurrentFrame,
|
||||
credits,
|
||||
modelSelector,
|
||||
onBeforeSubmit,
|
||||
}: PromptBox3DProps) => {
|
||||
//const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -651,6 +658,7 @@ export const PromptBox3D = ({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{modelSelector}
|
||||
{selectedImageModel?.supportsNewAspectRatio() && (
|
||||
<AspectRatioPicker
|
||||
model={selectedImageModel}
|
||||
|
||||
Reference in new issue
Block a user