Skip to content

Commit 42476df

Browse files
committed
fix: three issues - settings page, model facing, Ctrl+drag rotation
1. Settings page always showing wizard: - isFirstRun defaults to true in Zustand - Settings window is separate WebView, store not synced - Now fetches isFirstRun from backend on mount 2. SMD model not facing camera: - Pivot group wraps model, centered at origin - Default rotation Y=PI to face camera 3. Ctrl+drag to rotate 3D model view: - Ctrl key toggles pointer-events on render layer - Canvas receives mouse events when Ctrl held - Drag rotates pivot group (Y horizontal, X vertical) - X rotation clamped to ±60 degrees
1 parent 501a2fe commit 42476df

3 files changed

Lines changed: 107 additions & 42 deletions

File tree

src/App.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ChatWindow } from "./components/Chat/ChatWindow";
44
import { SettingsPanel } from "./components/Settings/SettingsPanel";
55
import { AISetupWizard } from "./components/Settings/AISetupWizard";
66
import { useSettingsStore } from "./stores/settingsStore";
7+
import { configAPI } from "./hooks/useAPI";
78

89
type Route = "pet" | "chat" | "settings";
910

@@ -16,7 +17,18 @@ function getRoute(): Route {
1617

1718
export default function App() {
1819
const [route, setRoute] = useState<Route>(getRoute);
19-
const { isFirstRun } = useSettingsStore();
20+
const { isFirstRun, setFirstRun } = useSettingsStore();
21+
const [loaded, setLoaded] = useState(route !== "settings");
22+
23+
// 设置窗口:从后端同步 isFirstRun 状态
24+
useEffect(() => {
25+
if (route === "settings") {
26+
configAPI.isFirstRun().then((val) => {
27+
setFirstRun(val);
28+
setLoaded(true);
29+
}).catch(() => setLoaded(true));
30+
}
31+
}, [route, setFirstRun]);
2032

2133
useEffect(() => {
2234
const onHashChange = () => setRoute(getRoute());
@@ -26,6 +38,9 @@ export default function App() {
2638

2739
if (route === "pet") return <PetWindow />;
2840
if (route === "chat") return <ChatWindow />;
29-
if (route === "settings") return isFirstRun ? <AISetupWizard /> : <SettingsPanel />;
41+
if (route === "settings") {
42+
if (!loaded) return null;
43+
return isFirstRun ? <AISetupWizard /> : <SettingsPanel />;
44+
}
3045
return null;
3146
}

src/components/Pet/PetWindow.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ import { useClickThrough } from "../../hooks/useClickThrough";
55
import { listen } from "@tauri-apps/api/event";
66
import type { PetStyle } from "../../types";
77

8-
/**
9-
* 宠物窗口 - 只在 pet 路由渲染,包含点击穿透逻辑
10-
*/
118
export function PetWindow() {
129
const { currentStyle, currentAnimation, setStyle } = usePetStore();
1310
const containerRef = useRef<HTMLDivElement>(null);
11+
const renderWrapRef = useRef<HTMLDivElement>(null);
12+
const ctrlHeld = useRef(false);
1413

1514
useClickThrough(containerRef);
1615

@@ -22,6 +21,32 @@ export function PetWindow() {
2221
return () => { unlisten.then((fn) => fn()); };
2322
}, [setStyle]);
2423

24+
// Ctrl 键:切换渲染层 pointer-events,允许 canvas 交互(旋转视角)
25+
useEffect(() => {
26+
const onKeyDown = (e: KeyboardEvent) => {
27+
if (e.key === "Control" && !ctrlHeld.current) {
28+
ctrlHeld.current = true;
29+
if (renderWrapRef.current) {
30+
renderWrapRef.current.style.pointerEvents = "auto";
31+
}
32+
}
33+
};
34+
const onKeyUp = (e: KeyboardEvent) => {
35+
if (e.key === "Control") {
36+
ctrlHeld.current = false;
37+
if (renderWrapRef.current) {
38+
renderWrapRef.current.style.pointerEvents = "none";
39+
}
40+
}
41+
};
42+
window.addEventListener("keydown", onKeyDown);
43+
window.addEventListener("keyup", onKeyUp);
44+
return () => {
45+
window.removeEventListener("keydown", onKeyDown);
46+
window.removeEventListener("keyup", onKeyUp);
47+
};
48+
}, []);
49+
2550
return (
2651
<div
2752
ref={containerRef}
@@ -34,7 +59,7 @@ export function PetWindow() {
3459
backgroundColor: "transparent",
3560
}}
3661
>
37-
<div style={{ pointerEvents: "none" }}>
62+
<div ref={renderWrapRef} style={{ pointerEvents: "none" }}>
3863
<PetRenderer
3964
style={currentStyle}
4065
animation={currentAnimation}

src/components/Pet/SMDRenderer.tsx

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
1616
clips: Map<string, THREE.AnimationClip>;
1717
currentAction?: THREE.AnimationAction;
1818
frameId?: number;
19-
}>({ clips: new Map() });
19+
pivot?: THREE.Group;
20+
// 视角控制
21+
rotY: number;
22+
rotX: number;
23+
dragging: boolean;
24+
lastMouse: { x: number; y: number };
25+
}>({ clips: new Map(), rotY: 0, rotX: 0, dragging: false, lastMouse: { x: 0, y: 0 } });
2026

2127
useEffect(() => {
2228
const container = containerRef.current;
@@ -34,8 +40,6 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
3440

3541
const scene = new THREE.Scene();
3642
const camera = new THREE.PerspectiveCamera(30, width / height, 0.1, 10000);
37-
camera.position.set(0, 3, 8);
38-
camera.lookAt(0, 2, 0);
3943

4044
scene.add(new THREE.AmbientLight(0xffffff, 2.0));
4145
const dirLight = new THREE.DirectionalLight(0xffffff, 0.5);
@@ -51,6 +55,31 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
5155
console.error("SMD 模型加载失败:", err);
5256
});
5357

58+
// Ctrl+拖拽旋转视角
59+
const canvas = renderer.domElement;
60+
const onMouseDown = (e: MouseEvent) => {
61+
if (e.ctrlKey) {
62+
state.dragging = true;
63+
state.lastMouse = { x: e.clientX, y: e.clientY };
64+
}
65+
};
66+
const onMouseMove = (e: MouseEvent) => {
67+
if (!state.dragging || !state.pivot) return;
68+
const dx = e.clientX - state.lastMouse.x;
69+
const dy = e.clientY - state.lastMouse.y;
70+
state.rotY += dx * 0.01;
71+
state.rotX += dy * 0.01;
72+
state.rotX = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, state.rotX));
73+
state.pivot.rotation.y = state.rotY;
74+
state.pivot.rotation.x = state.rotX;
75+
state.lastMouse = { x: e.clientX, y: e.clientY };
76+
};
77+
const onMouseUp = () => { state.dragging = false; };
78+
79+
canvas.addEventListener("mousedown", onMouseDown);
80+
window.addEventListener("mousemove", onMouseMove);
81+
window.addEventListener("mouseup", onMouseUp);
82+
5483
const clock = new THREE.Clock();
5584
const animate = () => {
5685
state.frameId = requestAnimationFrame(animate);
@@ -62,13 +91,17 @@ export function SMDRenderer({ animation, width, height }: PetRendererProps) {
6291

6392
return () => {
6493
if (state.frameId) cancelAnimationFrame(state.frameId);
94+
canvas.removeEventListener("mousedown", onMouseDown);
95+
window.removeEventListener("mousemove", onMouseMove);
96+
window.removeEventListener("mouseup", onMouseUp);
6597
renderer.dispose();
6698
if (container.contains(renderer.domElement)) {
6799
container.removeChild(renderer.domElement);
68100
}
69101
};
70102
}, [width, height]);
71103

104+
// 切换动画
72105
useEffect(() => {
73106
const state = stateRef.current;
74107
if (!state.mixer) return;
@@ -101,56 +134,53 @@ async function loadModel(
101134
mixer?: THREE.AnimationMixer;
102135
clips: Map<string, THREE.AnimationClip>;
103136
currentAction?: THREE.AnimationAction;
137+
pivot?: THREE.Group;
138+
rotY: number;
104139
},
105140
scene: THREE.Scene,
106141
camera: THREE.PerspectiveCamera,
107142
) {
108-
console.log("[SMD] 开始加载模型...");
109-
110143
const pqcResp = await fetch(`${MODEL_BASE}/8.pqc`);
111-
if (!pqcResp.ok) {
112-
throw new Error(`PQC 加载失败: ${pqcResp.status}`);
113-
}
114-
const pqcText = await pqcResp.text();
115-
const config = parsePQC(pqcText);
116-
console.log("[SMD] PQC 配置:", config);
144+
if (!pqcResp.ok) throw new Error(`PQC 加载失败: ${pqcResp.status}`);
145+
const config = parsePQC(await pqcResp.text());
117146

118147
const bodyResp = await fetch(`${MODEL_BASE}/${config.body}`);
119-
if (!bodyResp.ok) {
120-
throw new Error(`Body SMD 加载失败: ${bodyResp.status}`);
121-
}
122-
const bodyText = await bodyResp.text();
123-
const bodySMD = parseSMD(bodyText);
124-
console.log("[SMD] Body 解析完成:", bodySMD.triangles.length, "个三角形,", bodySMD.bones.length, "个骨骼");
148+
if (!bodyResp.ok) throw new Error(`Body SMD 加载失败: ${bodyResp.status}`);
149+
const bodySMD = parseSMD(await bodyResp.text());
125150

126-
const texture = await new THREE.TextureLoader().loadAsync(
127-
`${MODEL_BASE}/cresselia-lp.png`,
128-
);
151+
const texture = await new THREE.TextureLoader().loadAsync(`${MODEL_BASE}/cresselia-lp.png`);
129152
texture.colorSpace = THREE.SRGBColorSpace;
130-
console.log("[SMD] 贴图加载完成");
131153

132154
const { mesh } = buildSkinnedMesh(bodySMD, texture, config.scale);
133-
scene.add(mesh);
134155

135-
// 自动适配相机:计算模型包围盒
156+
// 用 pivot group 包裹模型,方便旋转
157+
const pivot = new THREE.Group();
158+
pivot.add(mesh);
159+
scene.add(pivot);
160+
state.pivot = pivot;
161+
162+
// 计算包围盒,自动适配相机
136163
const box = new THREE.Box3().setFromObject(mesh);
137164
const center = box.getCenter(new THREE.Vector3());
138165
const size = box.getSize(new THREE.Vector3());
139166
const maxDim = Math.max(size.x, size.y, size.z);
140167

141-
console.log("[SMD] 模型包围盒 center:", center, "size:", size, "maxDim:", maxDim);
168+
// 把 pivot 原点移到模型中心
169+
pivot.position.set(-center.x, -center.y, -center.z);
170+
171+
// 旋转模型到正面(SMD 模型默认朝向可能不对)
172+
// 先旋转 180 度让模型面向相机
173+
state.rotY = Math.PI;
174+
pivot.rotation.y = state.rotY;
142175

143-
// 相机对准模型中心,距离根据模型大小调整
144176
const fov = camera.fov * (Math.PI / 180);
145-
const dist = maxDim / (2 * Math.tan(fov / 2)) * 1.2;
146-
camera.position.set(center.x, center.y, center.z + dist);
147-
camera.lookAt(center);
177+
const dist = maxDim / (2 * Math.tan(fov / 2)) * 1.3;
178+
camera.position.set(0, 0, dist);
179+
camera.lookAt(0, 0, 0);
148180
camera.near = dist / 100;
149181
camera.far = dist * 100;
150182
camera.updateProjectionMatrix();
151183

152-
console.log("[SMD] 相机位置:", camera.position, "距离:", dist);
153-
154184
// 动画
155185
const mixer = new THREE.AnimationMixer(mesh);
156186
state.mixer = mixer;
@@ -159,11 +189,9 @@ async function loadModel(
159189
try {
160190
const resp = await fetch(`${MODEL_BASE}/${file}`);
161191
if (!resp.ok) continue;
162-
const text = await resp.text();
163-
const animSMD = parseSMD(text);
192+
const animSMD = parseSMD(await resp.text());
164193
const clip = buildAnimationClip(name, animSMD, bodySMD.bones, config.scale);
165194
state.clips.set(name, clip);
166-
console.log("[SMD] 动画加载:", name, clip.duration.toFixed(2) + "s");
167195
} catch (err) {
168196
console.warn("[SMD] 动画加载失败:", name, err);
169197
}
@@ -174,8 +202,5 @@ async function loadModel(
174202
const action = mixer.clipAction(idleClip);
175203
action.play();
176204
state.currentAction = action;
177-
console.log("[SMD] 播放 idle 动画");
178205
}
179-
180-
console.log("[SMD] 模型加载完成");
181206
}

0 commit comments

Comments
 (0)