571 lines
18 KiB
Dart
571 lines
18 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:animations/utils/binary_manager.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
import 'cup.dart';
|
||
|
||
Future<void> main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// 初始化3D模型资源
|
||
try {
|
||
await BinaryManager.initializeAppResources();
|
||
print('3D模型资源初始化成功');
|
||
} catch (e) {
|
||
print('无法初始化3D模型资源: $e');
|
||
}
|
||
|
||
runApp(const MyApp());
|
||
}
|
||
|
||
class MyApp extends StatelessWidget {
|
||
const MyApp({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp(
|
||
title: '3D模型纹理替换',
|
||
theme: ThemeData(
|
||
primarySwatch: Colors.blue,
|
||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||
),
|
||
home: ModelViewerWithTextureReplace(),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ModelViewerWithTextureReplace extends StatefulWidget {
|
||
const ModelViewerWithTextureReplace({super.key});
|
||
|
||
@override
|
||
State<ModelViewerWithTextureReplace> createState() =>
|
||
_ModelViewerWithTextureReplaceState();
|
||
}
|
||
|
||
class _ModelViewerWithTextureReplaceState
|
||
extends State<ModelViewerWithTextureReplace> {
|
||
bool _isLoading = true;
|
||
String? _modelPath;
|
||
String? _texturePath;
|
||
int _keyValue = 0;
|
||
String _statusMessage = "正在加载模型...";
|
||
bool _isReplacingTexture = false;
|
||
File? _selectedImage;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load3DModels();
|
||
// _loadModel();
|
||
}
|
||
|
||
// 加载3D模型文件
|
||
Future<void> _load3DModels() async {
|
||
try {
|
||
setState(() {
|
||
_statusMessage = "获取模型路径...";
|
||
});
|
||
|
||
// 使用BinaryManager获取3D模型文件路径
|
||
// final supportDir = await getApplicationSupportDirectory();
|
||
// final cupDir = Directory('${supportDir.path}/cup');
|
||
// final modelFile = File('${cupDir.path}/model.gltf');
|
||
|
||
final modelPath = await BinaryManager.get3DModelPath("model.gltf");
|
||
|
||
final texturePath = await BinaryManager.get3DModelPath("texture.jpg");
|
||
|
||
// print("路径: $modelPath");
|
||
// print("文件是否存在: ${await File(modelPath).exists()}");
|
||
// print("文件路径: ${await File(modelPath).uri}");
|
||
//
|
||
// setState(() {
|
||
// _modelPath = File(modelPath).uri.toString(); // 确保是本地路径
|
||
// });
|
||
|
||
// if (!await File(modelPath).exists()) {
|
||
// print("❌ 模型文件不存在: $modelPath");
|
||
// }
|
||
// if (!await File(texturePath).exists()) {
|
||
// print("❌ 纹理文件不存在: $texturePath");
|
||
// }
|
||
print('modelPath=${modelPath}\ntexturePath=${texturePath}');
|
||
setState(() {
|
||
_modelPath = modelPath;
|
||
_texturePath = texturePath;
|
||
_isLoading = false;
|
||
_statusMessage = "加载完成";
|
||
});
|
||
} catch (e) {
|
||
print('加载3D模型失败: $e');
|
||
setState(() {
|
||
_isLoading = false;
|
||
_statusMessage = "加载失败: $e";
|
||
});
|
||
}
|
||
}
|
||
|
||
// 使用FilePicker选择新纹理
|
||
Future<void> _pickNewTexture() async {
|
||
if (_isReplacingTexture || _texturePath == null) return;
|
||
|
||
setState(() {
|
||
_isReplacingTexture = true;
|
||
_statusMessage = "正在选择纹理文件...";
|
||
});
|
||
|
||
try {
|
||
// 选择图片文件
|
||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||
type: FileType.image,
|
||
allowMultiple: false,
|
||
);
|
||
|
||
if (result == null ||
|
||
result.files.isEmpty ||
|
||
result.files.single.path == null) {
|
||
setState(() {
|
||
_statusMessage = "取消选择";
|
||
});
|
||
return;
|
||
}
|
||
_selectedImage = File(result.files.single.path!);
|
||
|
||
final selectedFile = result.files.single;
|
||
final fileExtension = p.extension(selectedFile.path!).toLowerCase();
|
||
|
||
// 可选:仅允许特定图片格式
|
||
if (!['.jpg', '.jpeg', '.png'].contains(fileExtension)) {
|
||
setState(() {
|
||
_statusMessage = "仅支持 JPG / PNG 格式纹理";
|
||
});
|
||
return;
|
||
}
|
||
|
||
final newTexture = File(selectedFile.path!);
|
||
final supportDir = await getApplicationSupportDirectory();
|
||
final modelDir = p.join(supportDir.path, 'cup');
|
||
final modelPath = p.join(modelDir, 'model.gltf');
|
||
final modelFile = File(modelPath);
|
||
|
||
if (!await newTexture.exists()) {
|
||
setState(() {
|
||
_statusMessage = "所选纹理文件不存在";
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 复制新纹理为固定名 new.jpg 到模型目录
|
||
final newTexturePath = p.join(modelDir, 'new.jpg');
|
||
await newTexture.copy(newTexturePath);
|
||
_texturePath = newTexturePath;
|
||
|
||
// 修改 GLTF 文件
|
||
if (await modelFile.exists()) {
|
||
String gltfContent = await modelFile.readAsString();
|
||
|
||
// ✅ 修复 GLTF 中非法负号格式(例如 "- 0.123" => "-0.123")
|
||
gltfContent = gltfContent.replaceAllMapped(
|
||
RegExp(r'-\s+(\d+(\.\d+)?)'),
|
||
(match) => '-${match.group(1)}',
|
||
);
|
||
|
||
final gltfJson = jsonDecode(gltfContent);
|
||
|
||
// 安全修改 images[0].uri
|
||
if (gltfJson['images'] != null && gltfJson['images'].isNotEmpty) {
|
||
gltfJson['images'][0]['uri'] = './new.jpg';
|
||
}
|
||
|
||
// 可选:修改 textures[0].name
|
||
if (gltfJson['textures'] != null && gltfJson['textures'].isNotEmpty) {
|
||
gltfJson['textures'][0]['name'] = 'new.jpg';
|
||
}
|
||
|
||
// 写回更新后的内容
|
||
final updatedContent =
|
||
const JsonEncoder.withIndent(' ').convert(gltfJson);
|
||
await modelFile.writeAsString(updatedContent);
|
||
|
||
print('GLTF文件已成功更新');
|
||
|
||
// 重建模型视图
|
||
setState(() {
|
||
_keyValue++;
|
||
_statusMessage = "纹理已更新为 new.jpg";
|
||
});
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('纹理已成功更新为 new.jpg')),
|
||
);
|
||
} else {
|
||
print('GLTF文件不存在: $modelPath');
|
||
setState(() {
|
||
_statusMessage = "GLTF文件不存在";
|
||
});
|
||
}
|
||
} catch (e) {
|
||
print('更换纹理失败: $e');
|
||
setState(() {
|
||
_statusMessage = "更换纹理失败: $e";
|
||
});
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('更换纹理失败: $e')),
|
||
);
|
||
} finally {
|
||
setState(() {
|
||
_isReplacingTexture = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 重置纹理到原始状态
|
||
Future<void> _resetTexture() async {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => Cup(),
|
||
// builder: (context) => CupPage(),
|
||
),
|
||
);
|
||
return;
|
||
|
||
if (_isReplacingTexture || _texturePath == null) return;
|
||
|
||
setState(() {
|
||
_isReplacingTexture = true;
|
||
_statusMessage = "正在重置纹理...";
|
||
});
|
||
|
||
try {
|
||
// 检查备份文件是否存在
|
||
final backupTexturePath = '${_texturePath!}.backup';
|
||
final backupFile = File(backupTexturePath);
|
||
|
||
if (await backupFile.exists()) {
|
||
// 恢复备份文件
|
||
await backupFile.copy(_texturePath!);
|
||
|
||
// 给渲染器一点时间来处理文件变化
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
// 强制重建ViewerWidget以应用恢复的纹理
|
||
setState(() {
|
||
_keyValue++;
|
||
_statusMessage = "纹理已重置";
|
||
});
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('纹理已重置到原始状态')),
|
||
);
|
||
} else {
|
||
setState(() {
|
||
_statusMessage = "没有备份文件可恢复";
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('没有备份文件可恢复')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
print('重置纹理失败: $e');
|
||
setState(() {
|
||
_statusMessage = "重置纹理失败: $e";
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('重置纹理失败: $e')),
|
||
);
|
||
} finally {
|
||
setState(() {
|
||
_isReplacingTexture = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('3D模型纹理替换'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.texture),
|
||
onPressed: _isReplacingTexture ? null : _pickNewTexture,
|
||
tooltip: '更换纹理',
|
||
),
|
||
],
|
||
),
|
||
body: _isLoading
|
||
? Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const CircularProgressIndicator(),
|
||
const SizedBox(height: 16),
|
||
Text(_statusMessage),
|
||
],
|
||
),
|
||
)
|
||
: _modelPath == null
|
||
? Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error, size: 48, color: Colors.red),
|
||
const SizedBox(height: 16),
|
||
const Text('加载模型失败'),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton(
|
||
onPressed: _load3DModels,
|
||
child: const Text('重试'),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
if (_selectedImage != null)
|
||
Container(
|
||
width: 300,
|
||
height: 300,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: Colors.blue, width: 2),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: Image.file(
|
||
_selectedImage!,
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
),
|
||
// Expanded(
|
||
// child: ViewerWidget(
|
||
// key: ValueKey(_keyValue),
|
||
// assetPath: "file://$_modelPath",
|
||
// skyboxPath: "assets/default_env_skybox.ktx",
|
||
// iblPath: "assets/default_env_ibl.ktx",
|
||
// transformToUnitCube: true,
|
||
// initialCameraPosition: Vector3(0, 0, 6),
|
||
// background: Colors.grey[200],
|
||
// manipulatorType: ManipulatorType.ORBIT,
|
||
// initial: const Center(
|
||
// child: CircularProgressIndicator(),
|
||
// ),
|
||
// ),
|
||
// ),
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
color: Colors.grey[100],
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
ElevatedButton.icon(
|
||
icon: const Icon(Icons.texture),
|
||
label: const Text('选择纹理图片'),
|
||
onPressed: _isReplacingTexture
|
||
? null
|
||
: _pickNewTexture,
|
||
),
|
||
ElevatedButton.icon(
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('3d查看'),
|
||
onPressed:
|
||
_isReplacingTexture ? null : _resetTexture,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
_statusMessage,
|
||
style: TextStyle(
|
||
color: Colors.grey[600],
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
/*
|
||
import 'dart:async';
|
||
import 'package:animations/cup_page.dart';
|
||
import 'package:logging/logging.dart';
|
||
import 'package:flutter/material.dart' hide View;
|
||
import 'package:thermion_flutter/thermion_flutter.dart';
|
||
|
||
void main() {
|
||
runApp(const MyApp());
|
||
Logger.root.onRecord.listen((record) {
|
||
print(record);
|
||
});
|
||
}
|
||
|
||
class MyApp extends StatelessWidget {
|
||
const MyApp({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp(
|
||
title: 'Thermion Animation Demo',
|
||
theme: ThemeData(
|
||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||
useMaterial3: true,
|
||
),
|
||
home: const CupPage(),
|
||
);
|
||
}
|
||
}
|
||
|
||
class MyHomePage extends StatefulWidget {
|
||
const MyHomePage({super.key, required this.title});
|
||
final String title;
|
||
|
||
@override
|
||
State<MyHomePage> createState() => _MyHomePageState();
|
||
}
|
||
|
||
class _MyHomePageState extends State<MyHomePage> {
|
||
late DelegateInputHandler _inputHandler;
|
||
ThermionViewer? _thermionViewer;
|
||
|
||
ThermionAsset? _asset;
|
||
final _droneUri = "assets/cup/model.gltf";
|
||
final _cubeUri = "assets/cube_with_morph_targets.glb";
|
||
String? _loaded;
|
||
|
||
final gltfAnimations = <String>[];
|
||
int selectedGltfAnimation = -1;
|
||
|
||
Future _load(String? uri) async {
|
||
if (_asset != null) {
|
||
await _thermionViewer!.destroyAsset(_asset!);
|
||
_asset = null;
|
||
}
|
||
|
||
_loaded = uri;
|
||
if (uri != null) {
|
||
_asset = await _thermionViewer!.loadGltf(uri);
|
||
await _asset!.transformToUnitCube();
|
||
final animations = await _asset!.getGltfAnimationNames();
|
||
final durations = await Future.wait(List.generate(
|
||
animations.length, (i) => _asset!.getGltfAnimationDuration(i)));
|
||
|
||
final labels = animations
|
||
.asMap()
|
||
.map((index, animation) =>
|
||
MapEntry(index, "$animation (${durations[index]}s"))
|
||
.values;
|
||
gltfAnimations.clear();
|
||
gltfAnimations.addAll(labels);
|
||
selectedGltfAnimation = 0;
|
||
}
|
||
setState(() {});
|
||
}
|
||
|
||
Future _playGltfAnimation() async {
|
||
if (selectedGltfAnimation == -1) {
|
||
throw Exception();
|
||
}
|
||
await _asset!.playGltfAnimation(selectedGltfAnimation);
|
||
}
|
||
|
||
Future _stopGltfAnimation() async {
|
||
if (selectedGltfAnimation == -1) {
|
||
throw Exception();
|
||
}
|
||
await _asset!.stopGltfAnimation(selectedGltfAnimation);
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
_thermionViewer = await ThermionFlutterPlugin.createViewer();
|
||
|
||
final camera = await _thermionViewer!.getActiveCamera();
|
||
await camera.lookAt(Vector3(0, 0, 10));
|
||
|
||
await _thermionViewer!.loadSkybox("assets/default_env_skybox.ktx");
|
||
await _thermionViewer!.loadIbl("assets/default_env_ibl.ktx");
|
||
await _thermionViewer!.setPostProcessing(true);
|
||
await _thermionViewer!.setRendering(true);
|
||
|
||
_inputHandler = DelegateInputHandler.fixedOrbit(_thermionViewer!);
|
||
await _load(_droneUri);
|
||
|
||
setState(() {});
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_thermionViewer == null) {
|
||
return Container();
|
||
}
|
||
return Stack(children: [
|
||
Positioned.fill(
|
||
child: ThermionListenerWidget(
|
||
inputHandler: _inputHandler,
|
||
child: ThermionWidget(
|
||
viewer: _thermionViewer!,
|
||
))),
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||
child: Row(children: [
|
||
const Text("Asset: "),
|
||
DropdownButton<String?>(
|
||
value: _loaded,
|
||
items: [_droneUri, _cubeUri, null]
|
||
.map((uri) => DropdownMenuItem<String?>(
|
||
value: uri,
|
||
child: Text(
|
||
uri ?? "None",
|
||
style: const TextStyle(fontSize: 12),
|
||
)))
|
||
.toList(),
|
||
onChanged: _load),
|
||
const Text("Animation: "),
|
||
DropdownButton<String>(
|
||
value: selectedGltfAnimation == -1
|
||
? null
|
||
: gltfAnimations[selectedGltfAnimation],
|
||
items: gltfAnimations
|
||
.map((animation) => DropdownMenuItem<String>(
|
||
value: animation,
|
||
child: Text(
|
||
animation,
|
||
style: const TextStyle(fontSize: 12),
|
||
)))
|
||
.toList(),
|
||
onChanged: (value) {
|
||
if (value == null) {
|
||
selectedGltfAnimation = -1;
|
||
} else {
|
||
selectedGltfAnimation = gltfAnimations.indexOf(value);
|
||
}
|
||
}),
|
||
IconButton(
|
||
onPressed: _playGltfAnimation,
|
||
icon: const Icon(Icons.play_arrow)),
|
||
IconButton(
|
||
onPressed: _stopGltfAnimation, icon: const Icon(Icons.stop))
|
||
]))),
|
||
]);
|
||
}
|
||
}
|
||
*/
|