diff --git a/README.md b/README.md index 40419dc..d4c1e5a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ grouping the vertices into one single draw call). It is also a null-safe library ## Preview -![Failed to load Screenshot](https://github.com/arnovanliere/object_3d/blob/master/screenshot.png) +![Failed to load Screenshot](./screenshots/screenshot.png) +![Failed to load Screenshot](./screenshots/fresnel.png) +See more in [/screenshots](./screenshots/). ## Credits - All credits for the initial library go to [hemanthrajv](https://github.com/hemanthrajv/flutter_3d_obj). +- Gesture feedback, optimization, and face coloring customization are thanks to [TheMaverickProgrammer](https://github.com/TheMaverickProgrammer) in collaboration with [DBL](https://github.com/InstrinsicAutomations). diff --git a/analysis_options.yaml b/analysis_options.yaml index 5cba379..2d7a548 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -14,6 +14,21 @@ linter: prefer_final_in_for_each: true use_build_context_synchronously: true always_declare_return_types: true - always_specify_types: false - omit_local_variable_types: true - avoid_types_on_closure_parameters: true + always_specify_types: true + void_checks: true + use_raw_strings: true + unnecessary_this: true + unnecessary_string_interpolations: true + unnecessary_string_escapes: true + unnecessary_null_aware_assignments: true + unnecessary_new: true + unnecessary_late: true + unnecessary_lambdas: true + unnecessary_brace_in_string_interps: true + prefer_asserts_in_initializer_lists: true + prefer_asserts_with_message: true + lines_longer_than_80_chars: true + avoid_setters_without_getters: true + avoid_return_types_on_setters: true + annotate_overrides: true + always_put_required_named_parameters_first: true \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index e689daf..e6f5c3b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'dart:math' as math; +import 'package:vector_math/vector_math_64.dart' show Vector3; import 'package:object_3d/object_3d.dart'; void main() { @@ -29,16 +31,36 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { + // (uncomment line in Object3D constructor) + // ignore: unused_element + Face _fresnel(Face face) { + final color = Colors.blue; + final light = Vector3(0.0, 0.0, 100.0).normalized(); + double ln1 = light.dot(face.normal); + double s1 = 1.0 + face.v1.normalized().dot(face.normal); + double s2 = 1.0 + face.v2.normalized().dot(face.normal); + double s3 = 1.0 + face.v3.normalized().dot(face.normal); + double power = 2; + + Color c = Color.fromRGBO( + (color.red + math.pow(s1, power).round()).clamp(0, 255), + (color.green + math.pow(s2, power).round()).clamp(0, 255), + (color.blue + math.pow(s3, power).round()).clamp(0, 255), + 1.0 - ln1.abs()); + return face..setColors(c, c, c); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Object 3D Example'), ), - body: const Center( + body: Center( child: Object3D( - size: Size(400.0, 400.0), + size: const Size(400.0, 400.0), path: "assets/file.obj", + // faceColorFunc: _fresnel, // uncomment to see in action ), ), ); diff --git a/lib/object_3d.dart b/lib/object_3d.dart index 4e3c610..fce8310 100644 --- a/lib/object_3d.dart +++ b/lib/object_3d.dart @@ -6,13 +6,69 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:vector_math/vector_math.dart' as vmath; -import 'package:vector_math/vector_math.dart' show Vector3; +import 'package:vector_math/vector_math_64.dart' show Vector3, Matrix4; + +typedef FaceColorFunc = Face Function(Face face); + +/// Represents a face (3 vertices) with color data +class Face { + Vector3 _v1, _v2, _v3; + Vector3? _cachedNormal; + Color c1 = Colors.white, c2 = Colors.white, c3 = Colors.white; + Face(this._v1, this._v2, this._v3); + + void setColors(Color c1, Color c2, Color c3) { + this.c1 = c1; + this.c2 = c2; + this.c3 = c3; + } + + /// getters + Vector3 get v1 { + return _v1; + } + + Vector3 get v2 { + return _v2; + } + + Vector3 get v3 { + return _v1; + } + + /// setters - invalidate normal cache + set v1(Vector3 v) { + _cachedNormal = null; + _v1 = v; + } + + set v2(Vector3 v) { + _cachedNormal = null; + _v2 = v; + } + + set v3(Vector3 v) { + _cachedNormal = null; + _v3 = v; + } + + /// Calculate the unit normal vector of a face and cache the result + Vector3 get normal { + if (_cachedNormal != null) return Vector3.copy(_cachedNormal!); + + // Normal needs recalculating + final Vector3 p = Vector3.copy(_v2)..sub(_v1); + final Vector3 q = Vector3.copy(_v2)..sub(_v3); + _cachedNormal = p.cross(q).normalized(); + + return Vector3.copy(_cachedNormal!); + } +} class Object3D extends StatefulWidget { const Object3D({ - super.key, required this.size, + super.key, this.color = Colors.white, this.object, this.path, @@ -21,6 +77,7 @@ class Object3D extends StatefulWidget { this.maxSpeed = 10.0, this.reversePitch = true, this.reverseYaw = false, + this.faceColorFunc, }) : assert( object != null || path != null, 'You must provide an object or a path', @@ -28,6 +85,18 @@ class Object3D extends StatefulWidget { assert( object == null || path == null, 'You must provide an object or a path, not both', + ), + assert( + swipeCoef > 0, + 'Parameter swipeCoef must be a positive, non-zero real number.', + ), + assert( + dampCoef >= 0.001 && dampCoef <= 0.999, + 'Parameter dampCoef must be in the range [0.001, 0.999].', + ), + assert( + maxSpeed > 0, + 'Parameter maxSpeed must be positive, non-zero real number.', ); final Size size; @@ -39,6 +108,7 @@ class Object3D extends StatefulWidget { final double maxSpeed; // in rots per 16 ms final bool reversePitch; // if true, rotation direction is flipped for pitch final bool reverseYaw; // if true, rotation direction is flipped for yaw + final FaceColorFunc? faceColorFunc; // If unset, uses _defaultFaceColor() @override State createState() => _Object3DState(); @@ -48,7 +118,7 @@ class _Object3DState extends State { double _pitch = 15.0, _yaw = 45.0; double? _previousX, _previousY; double _deltaX = 0.0, _deltaY = 0.0; - List vertices = []; + List vertices = []; List> faces = >[]; late Timer _updateTimer; @@ -62,26 +132,13 @@ class _Object3DState extends State { _parseObj(widget.object!); } - assert( - widget.swipeCoef > 0, - 'Parameter swipeCoef must be a positive, non-zero real number.', - ); - assert( - widget.dampCoef >= 0.001 && widget.dampCoef <= 0.999, - 'Parameter dampCoef must be in the range [0.001, 0.999].', - ); - assert( - widget.maxSpeed > 0, - 'Parameter maxSpeed must be positive, non-zero real number.', - ); - _updateTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { if (!mounted) return; setState(() { - final adx = _deltaX.abs(); - final ady = _deltaY.abs(); - final sx = _deltaX < 0 ? -1 : 1; - final sy = _deltaY < 0 ? -1 : 1; + final double adx = _deltaX.abs(); + final double ady = _deltaY.abs(); + final int sx = _deltaX < 0 ? -1 : 1; + final int sy = _deltaY < 0 ? -1 : 1; _deltaX = math.min(widget.maxSpeed, adx) * sx * widget.dampCoef; _deltaY = math.min(widget.maxSpeed, ady) * sy * widget.dampCoef; @@ -102,16 +159,18 @@ class _Object3DState extends State { /// Parse the object file. void _parseObj(String obj) { - final vertices = []; - final faces = >[]; - final lines = obj.split('\n'); - for (var line in lines) { - const space = ' '; + final List vertices = []; + final List> faces = >[]; + final List lines = obj.split('\n'); + for (String line in lines) { + const String space = ' '; line = line.replaceAll(RegExp(r'\s+'), space); // Split into tokens and drop empty tokens - final chars = - line.split(space).where((v) => v.isNotEmpty).toList(growable: false); + final List chars = line + .split(space) + .where((String v) => v.isNotEmpty) + .toList(growable: false); if (chars.isEmpty) continue; @@ -124,8 +183,8 @@ class _Object3DState extends State { ), ); } else if (chars[0] == 'f') { - final face = []; - for (var i = 1; i < chars.length; i++) { + final List face = []; + for (int i = 1; i < chars.length; i++) { face.add(int.parse(chars[i].split('/')[0])); } faces.add(face); @@ -172,6 +231,7 @@ class _Object3DState extends State { color: widget.color, faces: faces, zoom: 200, + faceColorFunc: widget.faceColorFunc, ), ), ); @@ -189,8 +249,10 @@ class _ObjectPainter extends CustomPainter { final List vertices; final List> faces; - final vmath.Vector3 camera = Vector3(0.0, 0.0, 0.0); - final vmath.Vector3 light = Vector3(0.0, 0.0, 100.0).normalized(); + final Vector3 camera = Vector3(0.0, 0.0, 0.0); + final Vector3 light = Vector3(0.0, 0.0, 100.0).normalized(); + + final FaceColorFunc? faceColorFunc; _ObjectPainter({ required this.size, @@ -201,28 +263,13 @@ class _ObjectPainter extends CustomPainter { required this.color, required this.faces, required this.zoom, + this.faceColorFunc, }); - /// Calculate the normal vector of a face. - Vector3 _normalVector3(Vector3 first, Vector3 second, Vector3 third) { - final secondFirst = Vector3.copy(second)..sub(first); - final secondThird = Vector3.copy(second)..sub(third); - return Vector3( - (secondFirst.y * secondThird.z) - (secondFirst.z * secondThird.y), - (secondFirst.z * secondThird.x) - (secondFirst.x * secondThird.z), - (secondFirst.x * secondThird.y) - (secondFirst.y * secondThird.x), - ); - } - - /// Multiply two vectors. - double _scalarMultiplication(Vector3 first, Vector3 second) { - return (first.x * second.x) + (first.y * second.y) + (first.z * second.z); - } - /// Calculate the position of a vertex in the 3D space based /// on the angle of rotation, view-port position and zoom. Vector3 _calcVertex(Vector3 vertex) { - final t = vmath.Matrix4.translationValues(_viewPortX, _viewPortY, 0); + final Matrix4 t = Matrix4.translationValues(_viewPortX, _viewPortY, 0); t.scale(zoom, -zoom); t.rotateX(_degreeToRadian(pitch)); t.rotateY(_degreeToRadian(yaw)); @@ -237,15 +284,15 @@ class _ObjectPainter extends CustomPainter { /// Calculate the 2D-positions of a vertex in the 3D space. List _drawFace(List vertices, List face) { - final coordinates = []; - for (var i = 0; i < face.length; i++) { + final List coordinates = []; + for (int i = 0; i < face.length; i++) { double x, y; if (i < face.length - 1) { - final iV = vertices[face[i + 1] - 1]; + final Vector3 iV = vertices[face[i + 1] - 1]; x = iV.x.toDouble(); y = iV.y.toDouble(); } else { - final iV = vertices[face[0] - 1]; + final Vector3 iV = vertices[face[0] - 1]; x = iV.x.toDouble(); y = iV.y.toDouble(); } @@ -254,70 +301,80 @@ class _ObjectPainter extends CustomPainter { return coordinates; } - /// Calculate the normal vector of a face. - Vector3 _normalVector(List verticesToDraw, List face) { - final first = verticesToDraw[face[0] - 1]; - final second = verticesToDraw[face[1] - 1]; - final third = verticesToDraw[face[2] - 1]; - return _normalVector3(first, second, third).normalized(); - } - /// Calculate the color of a vertex based on the /// position of the vertex and the light. - List _calcColor(Color color, Vector3 normalVector) { - final s = _scalarMultiplication(normalVector, light); - final double coefficient = math.max(0, s); - final c = Color.fromRGBO( + Face _defaultFaceColor(Face face) { + final double s = face.normal.dot(light); + final num coefficient = math.max(0, s); + final Color c = Color.fromRGBO( (color.red * coefficient).round(), (color.green * coefficient).round(), (color.blue * coefficient).round(), 1, ); - return [c, c, c]; + face.setColors(c, c, c); + return face; } /// Order vertices by the distance to the camera. List _sortVertices(List vertices) { - final avgOfZ = []; - for (var i = 0; i < faces.length; i++) { - final face = faces[i]; - var z = 0.0; - for (final i in face) { + final List avgOfZ = []; + for (int i = 0; i < faces.length; i++) { + final List face = faces[i]; + double z = 0.0; + for (final int i in face) { z += vertices[i - 1].z; } avgOfZ.add(AvgZ(i, z)); } - avgOfZ.sort((a, b) => a.z.compareTo(b.z)); + avgOfZ.sort((AvgZ a, AvgZ b) => a.z.compareTo(b.z)); return avgOfZ; } @override void paint(Canvas canvas, Size size) { // Calculate the position of the vertices in the 3D space. - final verticesToDraw = []; - for (final vertex in vertices) { - final defV = _calcVertex(Vector3.copy(vertex)); + final List verticesToDraw = []; + for (final Vector3 vertex in vertices) { + final Vector3 defV = _calcVertex(Vector3.copy(vertex)); verticesToDraw.add(defV); } // Order vertices by the distance to the camera. - final avgOfZ = _sortVertices(verticesToDraw); + final List avgOfZ = _sortVertices(verticesToDraw); // Calculate the position of the vertices in the 2D space // and calculate the colors of the vertices. - final offsets = []; - final colors = []; - for (var i = 0; i < faces.length; i++) { - final face = faces[avgOfZ[i].index]; - final n = _normalVector(verticesToDraw, face); - colors.addAll(_calcColor(color, n)); - offsets.addAll(_drawFace(verticesToDraw, face)); + final List offsets = []; + final List colors = []; + for (int i = 0; i < faces.length; i++) { + final List faceIdx = faces[avgOfZ[i].index]; + + // Allocate list with a fixed size of 3 + final List verts = + List.filled(3, Vector3.zero(), growable: false); + + verts[0] = verticesToDraw[faceIdx[0] - 1]; + verts[1] = verticesToDraw[faceIdx[1] - 1]; + verts[2] = verticesToDraw[faceIdx[2] - 1]; + + Face face = Face( + verts[0], + verts[1], + verts[2], + ); + + // Fallback on default color func if a custom one is not provided + face = faceColorFunc?.call(face) ?? _defaultFaceColor(face); + + colors.addAll([face.c1, face.c2, face.c3]); + offsets.addAll(_drawFace(verticesToDraw, faceIdx)); } // Draw the vertices. - final paint = Paint(); + final Paint paint = Paint(); paint.style = PaintingStyle.fill; paint.color = color; - final v = Vertices(VertexMode.triangles, offsets, colors: colors); + final Vertices v = Vertices(VertexMode.triangles, offsets, colors: colors); canvas.drawVertices(v, BlendMode.clear, paint); } diff --git a/screenshots/fresnel.png b/screenshots/fresnel.png new file mode 100644 index 0000000..79e1dea Binary files /dev/null and b/screenshots/fresnel.png differ diff --git a/screenshots/glass.png b/screenshots/glass.png new file mode 100644 index 0000000..53e7ab1 Binary files /dev/null and b/screenshots/glass.png differ diff --git a/screenshot.png b/screenshots/screenshot.png similarity index 100% rename from screenshot.png rename to screenshots/screenshot.png