feat: add ai-cad camera orientation cube
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-07 12:14:57 +08:00
parent aede89654f
commit a26e76638e
5 changed files with 199 additions and 15 deletions

View File

@ -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

View File

@ -641,6 +641,7 @@ export default function CanvasModel3DPage() {
// Viewer Settings
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>({
cameraPreset: "iso",
envPreset: "city",
lightIntensity: 1,
autoRotate: false,

View File

@ -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]">
从右侧输入设计意图,模型会直接出现在画布中。

View File

@ -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>

View File

@ -76,6 +76,7 @@ type World3DViewerProps = {
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
cameraPreset: "iso",
envPreset: "city",
lightIntensity: 1,
autoRotate: false,