feat(app): 穿搭生成 + 方案流 + 效果图 + 3D 化身查看
- outfit_provider:方案列表/生成任务轮询(pending→planning→scoring→rendering→done)/详情/选定主方案/收藏 - outfit_page:日期范围 + 地点生成表单,生成中显示任务进度,方案列表卡片 - plan_viewer_page:PageView 手指滑动切换方案,穿衣清单(衣橱/新品标记)、发型发色、设为主方案/收藏 - plan_effect_page:效果图 3 视角(正面/侧面/背面)查看 - avatar_viewer:three_dart + flutter_gl 3D 渲染实现(自动旋转),GLB 资产未接入时占位展示,kEnable3D 开关 - avatar_viewer_page:化身查看页 + 重新构建 - 新增 /plan-viewer /plan-effect /avatar-viewer 路由
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gl/flutter_gl.dart';
|
||||
import 'package:three_dart/three_dart.dart' as three;
|
||||
import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm;
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
|
||||
/// 是否启用 three_dart 3D 渲染。
|
||||
/// MVP 阶段服务端尚无预烘焙 GLB 资产(/workspace/templates/*.glb 为构建产物),
|
||||
/// 默认关闭展示信息占位;接入真实 GLB 资源后置为 true 即可启用。
|
||||
const bool kEnable3D = false;
|
||||
|
||||
/// 3D 化身查看组件:three_dart 渲染 GLB,加载失败/未启用时展示信息占位
|
||||
class AvatarViewer extends StatelessWidget {
|
||||
final String? glbUrl;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const AvatarViewer({
|
||||
super.key,
|
||||
required this.glbUrl,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.height = 360,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kEnable3D || glbUrl == null || glbUrl!.isEmpty) {
|
||||
return _AvatarPlaceholder(
|
||||
title: title, subtitle: subtitle, height: height);
|
||||
}
|
||||
return _ThreeDAvatarViewer(
|
||||
url: AppConfig.resolveUrl(glbUrl!),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// three_dart 实现:flutter_gl 初始化 + GLTFLoader 加载 + 自动旋转渲染
|
||||
class _ThreeDAvatarViewer extends StatefulWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const _ThreeDAvatarViewer({
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ThreeDAvatarViewer> createState() => _ThreeDAvatarViewerState();
|
||||
}
|
||||
|
||||
class _ThreeDAvatarViewerState extends State<_ThreeDAvatarViewer> {
|
||||
FlutterGlPlugin? _gl;
|
||||
three.WebGLRenderer? _renderer;
|
||||
three.Scene? _scene;
|
||||
three.Camera? _camera;
|
||||
three.Object3D? _model;
|
||||
dynamic _sourceTexture;
|
||||
double _width = 300;
|
||||
double _dpr = 1;
|
||||
bool _ready = false;
|
||||
bool _failed = false;
|
||||
bool _disposed = false;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final mq = MediaQuery.of(context);
|
||||
_width = mq.size.width;
|
||||
_dpr = mq.devicePixelRatio;
|
||||
try {
|
||||
final gl = FlutterGlPlugin();
|
||||
await gl.initialize(options: {
|
||||
'antialias': true,
|
||||
'alpha': false,
|
||||
'width': _width.toInt(),
|
||||
'height': widget.height.toInt(),
|
||||
'dpr': _dpr,
|
||||
});
|
||||
await gl.prepareContext();
|
||||
|
||||
final renderer = three.WebGLRenderer({
|
||||
'width': _width,
|
||||
'height': widget.height,
|
||||
'gl': gl.gl,
|
||||
'antialias': true,
|
||||
'canvas': gl.element,
|
||||
});
|
||||
renderer.setPixelRatio(_dpr);
|
||||
renderer.setSize(_width, widget.height, false);
|
||||
|
||||
final target = three.WebGLMultisampleRenderTarget(
|
||||
(_width * _dpr).toInt(), (widget.height * _dpr).toInt(),
|
||||
three.WebGLRenderTargetOptions({'format': three.RGBAFormat}));
|
||||
target.samples = 4;
|
||||
renderer.setRenderTarget(target);
|
||||
final sourceTexture = renderer.getRenderTargetGLTexture(target);
|
||||
|
||||
await _loadModel(gl, renderer);
|
||||
|
||||
_gl = gl;
|
||||
_renderer = renderer;
|
||||
_sourceTexture = sourceTexture;
|
||||
if (mounted) setState(() => _ready = true);
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 33), (_) => _render());
|
||||
} catch (e) {
|
||||
debugPrint('3D 渲染不可用,回退占位:$e');
|
||||
if (mounted) setState(() => _failed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadModel(
|
||||
FlutterGlPlugin gl, three.WebGLRenderer renderer) async {
|
||||
final scene = three.Scene();
|
||||
final camera = three.PerspectiveCamera(45, _width / widget.height, 0.1, 100);
|
||||
camera.position.set(0, 1.4, 3.2);
|
||||
scene.add(three.AmbientLight(0xffffff, 0.9));
|
||||
final keyLight = three.DirectionalLight(0xffffff, 0.9);
|
||||
keyLight.position.set(2, 4, 3);
|
||||
scene.add(keyLight);
|
||||
scene.add(camera);
|
||||
camera.lookAt(three.Vector3(0, 1, 0));
|
||||
|
||||
final loader = three_jsm.GLTFLoader(null);
|
||||
final result = await loader.loadAsync(widget.url);
|
||||
final model = result['scene'] as three.Object3D?;
|
||||
if (model != null) {
|
||||
model.rotation.y = 0.6;
|
||||
scene.add(model);
|
||||
}
|
||||
|
||||
_scene = scene;
|
||||
_camera = camera;
|
||||
_model = model;
|
||||
}
|
||||
|
||||
void _render() {
|
||||
if (_disposed || _renderer == null || _scene == null || _camera == null) {
|
||||
return;
|
||||
}
|
||||
final model = _model;
|
||||
if (model != null) {
|
||||
model.rotation.y += 0.01; // 自动旋转
|
||||
}
|
||||
_renderer!.render(_scene!, _camera!);
|
||||
_gl!.gl.flush();
|
||||
if (!kIsWeb) {
|
||||
_gl!.updateTexture(_sourceTexture);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_failed) {
|
||||
return _AvatarPlaceholder(
|
||||
title: widget.title, subtitle: widget.subtitle, height: widget.height);
|
||||
}
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
width: double.infinity,
|
||||
child: _ready
|
||||
? (kIsWeb
|
||||
? HtmlElementView(viewType: _gl!.textureId!.toString())
|
||||
: Texture(textureId: _gl!.textureId!))
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位:GLB 资产未接入或加载失败时展示化身信息
|
||||
class _AvatarPlaceholder extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double height;
|
||||
|
||||
const _AvatarPlaceholder({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [scheme.primaryContainer, scheme.primary.withValues(alpha: 0.3)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(title,
|
||||
style:
|
||||
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
subtitle.isEmpty ? '3D 渲染接入中(v2)' : subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user