feat: add ai-cad camera orientation cube
This commit is contained in:
parent
aede89654f
commit
a26e76638e
@ -12,6 +12,7 @@ const FINAL_TASK_STATUS = new Set([
|
||||
]);
|
||||
|
||||
const DEFAULT_VIEWER_SETTINGS = {
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
@ -55,6 +56,13 @@ export function normalizeViewerSettings(
|
||||
}
|
||||
|
||||
return {
|
||||
cameraPreset:
|
||||
value.cameraPreset === "top" ||
|
||||
value.cameraPreset === "front" ||
|
||||
value.cameraPreset === "right" ||
|
||||
value.cameraPreset === "iso"
|
||||
? value.cameraPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||
envPreset:
|
||||
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||
? value.envPreset
|
||||
|
||||
@ -641,6 +641,7 @@ export default function CanvasModel3DPage() {
|
||||
|
||||
// Viewer Settings
|
||||
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>({
|
||||
cameraPreset: "iso",
|
||||
envPreset: "city",
|
||||
lightIntensity: 1,
|
||||
autoRotate: false,
|
||||
|
||||
@ -193,6 +193,7 @@ const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||||
]);
|
||||
|
||||
const DEFAULT_VIEWER_SETTINGS: Model3DViewerSettings = {
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
@ -1815,6 +1816,13 @@ function normalizeViewerSettings(value: unknown): Model3DViewerSettings {
|
||||
|
||||
return {
|
||||
...DEFAULT_VIEWER_SETTINGS,
|
||||
cameraPreset:
|
||||
value.cameraPreset === "top" ||
|
||||
value.cameraPreset === "front" ||
|
||||
value.cameraPreset === "right" ||
|
||||
value.cameraPreset === "iso"
|
||||
? value.cameraPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||
envPreset,
|
||||
lightIntensity:
|
||||
typeof value.lightIntensity === "number" &&
|
||||
@ -2174,6 +2182,107 @@ function AiCadToolbarGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function AiCadOrientationCube({
|
||||
className,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
className?: string;
|
||||
onChange: (preset: Model3DViewerSettings["cameraPreset"]) => void;
|
||||
value: Model3DViewerSettings["cameraPreset"];
|
||||
}) {
|
||||
const getFaceClasses = (preset: Model3DViewerSettings["cameraPreset"]) =>
|
||||
cn(
|
||||
"cursor-pointer transition-all duration-150 ease-out",
|
||||
value === preset
|
||||
? "fill-[#e9f7ef] stroke-[#2f8f5b]"
|
||||
: "fill-[#f7f3ea] stroke-[#c9c0ae] hover:fill-[#f1ebde]"
|
||||
);
|
||||
|
||||
const getLabelClasses = (preset: Model3DViewerSettings["cameraPreset"]) =>
|
||||
cn(
|
||||
"pointer-events-none select-none fill-[#6f695d] text-[5px] font-medium uppercase tracking-[0.08em]",
|
||||
value === preset && "fill-[#1e5c3a]"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-6 bottom-6 z-10 flex flex-col items-center gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full border border-[#ddd7ca] bg-white/88 p-2 shadow-[0_10px_30px_rgba(108,95,72,0.12)] backdrop-blur-sm">
|
||||
<svg
|
||||
aria-label="3D 视角控制"
|
||||
className="h-[84px] w-[84px]"
|
||||
role="img"
|
||||
viewBox="0 0 64 64"
|
||||
>
|
||||
<g onClick={() => onChange("top")}>
|
||||
<polygon
|
||||
className={getFaceClasses("top")}
|
||||
points="32,7 50,18 32,28 14,18"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<text
|
||||
className={getLabelClasses("top")}
|
||||
textAnchor="middle"
|
||||
x="32"
|
||||
y="19.5"
|
||||
>
|
||||
Top
|
||||
</text>
|
||||
</g>
|
||||
<g onClick={() => onChange("front")}>
|
||||
<polygon
|
||||
className={getFaceClasses("front")}
|
||||
points="14,18 32,28 32,49 14,39"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<text
|
||||
className={getLabelClasses("front")}
|
||||
textAnchor="middle"
|
||||
x="23"
|
||||
y="38.5"
|
||||
>
|
||||
Front
|
||||
</text>
|
||||
</g>
|
||||
<g onClick={() => onChange("right")}>
|
||||
<polygon
|
||||
className={getFaceClasses("right")}
|
||||
points="32,28 50,18 50,39 32,49"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<text
|
||||
className={getLabelClasses("right")}
|
||||
textAnchor="middle"
|
||||
x="41"
|
||||
y="38.5"
|
||||
>
|
||||
Right
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-[0.16em] transition-all duration-150 ease-out",
|
||||
value === "iso"
|
||||
? "border-[#b7dcc5] bg-[#eefcf4] text-[#173225]"
|
||||
: "border-[#ddd7ca] bg-white/92 text-[#6f695d] hover:bg-[#faf7ef]"
|
||||
)}
|
||||
onClick={() => onChange("iso")}
|
||||
type="button"
|
||||
>
|
||||
Iso
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _AiCadMessageActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
@ -4126,6 +4235,16 @@ export function AiCadWorkbench() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewerCameraPresetChange(
|
||||
preset: Model3DViewerSettings["cameraPreset"]
|
||||
) {
|
||||
setViewerSettings((current) => ({
|
||||
...current,
|
||||
autoRotate: false,
|
||||
cameraPreset: preset,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col overflow-hidden bg-[#f8f7f3] text-[#201c16]">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@ -5744,7 +5863,7 @@ export function AiCadWorkbench() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-[360px] bg-[#efeadf]">
|
||||
<div className="relative min-h-[360px] bg-[#efeadf]">
|
||||
<Model3DViewer
|
||||
emptyStateTitle={
|
||||
activeVersion?.codeUrl
|
||||
@ -5761,6 +5880,12 @@ export function AiCadWorkbench() {
|
||||
}
|
||||
settings={viewerSettings}
|
||||
/>
|
||||
<AiCadOrientationCube
|
||||
onChange={
|
||||
handleViewerCameraPresetChange
|
||||
}
|
||||
value={viewerSettings.cameraPreset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6623,13 +6748,10 @@ export function AiCadWorkbench() {
|
||||
<div className="-translate-x-1/2 absolute top-[12%] left-1/2 h-[72%] w-px bg-[#ddd6c9]" />
|
||||
<div className="-translate-y-1/2 absolute top-1/2 left-[8%] h-px w-[74%] bg-[#ddd6c9]" />
|
||||
<div className="-translate-x-1/2 absolute top-[22%] left-1/2 h-[56%] w-[42%] rounded-[999px] border border-[#efe8db]" />
|
||||
<div className="absolute right-8 bottom-8 flex h-[92px] w-[92px] items-center justify-center rounded-full border border-[#ddd7ca] bg-[#f6f3eb] text-[#7f7768] text-[11px] shadow-sm">
|
||||
<div className="text-center leading-4">
|
||||
<div>Top</div>
|
||||
<div>Front</div>
|
||||
<div>Right</div>
|
||||
</div>
|
||||
</div>
|
||||
<AiCadOrientationCube
|
||||
onChange={handleViewerCameraPresetChange}
|
||||
value={viewerSettings.cameraPreset}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-10 flex justify-center px-6">
|
||||
<div className="text-center text-[#948d7f] text-[13px]">
|
||||
从右侧输入设计意图,模型会直接出现在画布中。
|
||||
|
||||
@ -8,9 +8,9 @@ import {
|
||||
OrbitControls,
|
||||
useGLTF,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
|
||||
import React, { Suspense, useEffect, useMemo } from "react";
|
||||
import React, { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import type { Mesh, Object3D } from "three";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@ -27,7 +27,10 @@ export type Model3DStats = {
|
||||
meshCount: number;
|
||||
};
|
||||
|
||||
export type Model3DCameraPreset = "front" | "iso" | "right" | "top";
|
||||
|
||||
export type Model3DViewerSettings = {
|
||||
cameraPreset: Model3DCameraPreset;
|
||||
envPreset:
|
||||
| "city"
|
||||
| "sunset"
|
||||
@ -55,6 +58,47 @@ type Model3DViewerProps = {
|
||||
onStatsChange?: (stats: Model3DStats) => void;
|
||||
};
|
||||
|
||||
const CAMERA_PRESET_POSITIONS: Record<
|
||||
Model3DCameraPreset,
|
||||
[number, number, number]
|
||||
> = {
|
||||
front: [0, 1.1, 3.2],
|
||||
iso: [2, 1.5, 2],
|
||||
right: [3.2, 1.1, 0],
|
||||
top: [0, 3.4, 0.001],
|
||||
};
|
||||
|
||||
function CameraPresetController({
|
||||
controlsRef,
|
||||
preset,
|
||||
}: {
|
||||
controlsRef: React.MutableRefObject<any>;
|
||||
preset: Model3DCameraPreset;
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const [x, y, z] =
|
||||
CAMERA_PRESET_POSITIONS[preset] || CAMERA_PRESET_POSITIONS.iso;
|
||||
|
||||
camera.position.set(x, y, z);
|
||||
if (preset === "top") {
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const controls = controlsRef.current;
|
||||
if (controls) {
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
}, [camera, controlsRef, preset]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
class ViewerErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: React.ReactNode },
|
||||
@ -231,6 +275,7 @@ export function Model3DViewer({
|
||||
onLayersChange,
|
||||
onStatsChange,
|
||||
}: Model3DViewerProps) {
|
||||
const controlsRef = useRef<any>(null);
|
||||
const resolvedModelUrl = useMemo(() => {
|
||||
const normalized = String(modelUrl || "").trim();
|
||||
if (!normalized) return "";
|
||||
@ -248,10 +293,7 @@ export function Model3DViewer({
|
||||
|
||||
if (!modelUrl) {
|
||||
return (
|
||||
<EmptyState
|
||||
previewImageUrl={previewImageUrl}
|
||||
title={emptyStateTitle}
|
||||
/>
|
||||
<EmptyState previewImageUrl={previewImageUrl} title={emptyStateTitle} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -294,9 +336,18 @@ export function Model3DViewer({
|
||||
}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [2, 1.5, 2], fov: 45 }}
|
||||
camera={{
|
||||
position:
|
||||
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
|
||||
CAMERA_PRESET_POSITIONS.iso,
|
||||
fov: 45,
|
||||
}}
|
||||
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
|
||||
>
|
||||
<CameraPresetController
|
||||
controlsRef={controlsRef}
|
||||
preset={settings.cameraPreset}
|
||||
/>
|
||||
<ambientLight intensity={0.5 * settings.lightIntensity} />
|
||||
<directionalLight
|
||||
intensity={1.2 * settings.lightIntensity}
|
||||
@ -352,6 +403,7 @@ export function Model3DViewer({
|
||||
makeDefault
|
||||
maxPolarAngle={Math.PI / 1.75}
|
||||
minPolarAngle={0}
|
||||
ref={controlsRef}
|
||||
/>
|
||||
</Canvas>
|
||||
</ViewerErrorBoundary>
|
||||
|
||||
@ -76,6 +76,7 @@ type World3DViewerProps = {
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
cameraPreset: "iso",
|
||||
envPreset: "city",
|
||||
lightIntensity: 1,
|
||||
autoRotate: false,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user