library panorama; import 'dart:async'; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_cube/flutter_cube.dart'; import 'package:motion_sensors/motion_sensors.dart'; enum SensorControl { /// No sensor used. None, /// Use gyroscope and accelerometer. Orientation, /// Use magnetometer and accelerometer. The logitude 0 points to north. AbsoluteOrientation, } class Panorama extends StatefulWidget { Panorama({ Key key, this.latitude = 0, this.longitude = 0, this.zoom = 1.0, this.minLatitude = -90.0, this.maxLatitude = 90.0, this.minLongitude = -180.0, this.maxLongitude = 180.0, this.minZoom = 1.0, this.maxZoom = 5.0, this.sensitivity = 1.0, this.animSpeed = 0.0, this.animReverse = true, this.latSegments = 32, this.lonSegments = 64, this.interactive = true, this.sensorControl = SensorControl.None, this.onViewChanged, this.child, }) : super(key: key); /// The initial latitude, in degrees, between -90 and 90. default to 0 (the vertical center of the image). final double latitude; /// The initial longitude, in degrees, between -180 and 180. default to 0 (the horizontal center of the image). final double longitude; /// The initial zoom, default to 1.0. final double zoom; /// The minimal latitude to show. default to -90.0 final double minLatitude; /// The maximal latitude to show. default to 90.0 final double maxLatitude; /// The minimal longitude to show. default to -180.0 final double minLongitude; /// The maximal longitude to show. default to 180.0 final double maxLongitude; /// The minimal zomm. default to 1.0 final double minZoom; /// The maximal zomm. default to 5.0 final double maxZoom; /// The sensitivity of the gesture. default to 1.0 final double sensitivity; /// The Speed of rotation by animation. default to 0.0 final double animSpeed; /// Reverse rotation when the current longitude reaches the minimal or maximum. default to true final bool animReverse; /// The number of vertical divisions of the sphere. final int latSegments; /// The number of horizontal divisions of the sphere. final int lonSegments; /// Interact with the panorama. default to true final bool interactive; /// Control the panorama with motion sensors. final SensorControl sensorControl; /// It is called when the view direction has changed, sending the new longitude and latitude values back. final Function(double longitude, double latitude, double tilt) onViewChanged; /// Specify an Image(equirectangular image) widget to the panorama. final Image child; @override _PanoramaState createState() => _PanoramaState(); } class _PanoramaState extends State with SingleTickerProviderStateMixin { Scene scene; double latitude; double longitude; double latitudeDelta = 0; double longitudeDelta = 0; double zoomDelta = 0; Offset _lastFocalPoint; double _lastZoom; double _radius = 500; double _dampingFactor = 0.05; double _animateDirection = 1.0; AnimationController _controller; double screenOrientation = 0.0; Vector3 orientation = Vector3(0, radians(90), 0); StreamSubscription _orientationSubscription; StreamSubscription _screenOrientSubscription; void _handleScaleStart(ScaleStartDetails details) { _lastFocalPoint = details.localFocalPoint; _lastZoom = null; } void _handleScaleUpdate(ScaleUpdateDetails details) { final offset = details.localFocalPoint - _lastFocalPoint; _lastFocalPoint = details.localFocalPoint; latitudeDelta += widget.sensitivity * 0.5 * math.pi * offset.dy / scene.camera.viewportHeight; longitudeDelta -= widget.sensitivity * _animateDirection * 0.5 * math.pi * offset.dx / scene.camera.viewportHeight; if (_lastZoom == null) { _lastZoom = scene.camera.zoom; } zoomDelta += _lastZoom * details.scale - (scene.camera.zoom + zoomDelta); if (widget.sensorControl == SensorControl.None && !_controller.isAnimating) { _controller.reset(); if (widget.animSpeed != 0) { _controller.repeat(); } else _controller.forward(); } } void _updateView() { if (scene == null) return; // auto rotate longitudeDelta += 0.001 * widget.animSpeed; // animate vertical rotating latitude += latitudeDelta * _dampingFactor * widget.sensitivity; latitudeDelta *= 1 - _dampingFactor * widget.sensitivity; // animate horizontal rotating longitude += _animateDirection * longitudeDelta * _dampingFactor * widget.sensitivity; longitudeDelta *= 1 - _dampingFactor * widget.sensitivity; // animate zomming final double zoom = scene.camera.zoom + zoomDelta * _dampingFactor; zoomDelta *= 1 - _dampingFactor; scene.camera.zoom = zoom.clamp(widget.minZoom, widget.maxZoom); // stop animation if not needed if (latitudeDelta.abs() < 0.001 && longitudeDelta.abs() < 0.001 && zoomDelta.abs() < 0.001) { if (widget.animSpeed == 0 && _controller.isAnimating) _controller.stop(); } // rotate for screen orientation Quaternion q = Quaternion.axisAngle(Vector3(0, 0, 1), screenOrientation); // rotate for device orientation q *= Quaternion.euler(-orientation.z, -orientation.y, -orientation.x); // rotate to latitude zero q *= Quaternion.axisAngle(Vector3(1, 0, 0), math.pi * 0.5); // check and limit the rotation range Vector3 o = quaternionToOrientation(q); final double minLat = radians(math.max(-89.9, widget.minLatitude)); final double maxLat = radians(math.min(89.9, widget.maxLatitude)); final double minLon = radians(widget.minLongitude); final double maxLon = radians(widget.maxLongitude); final double lat = (-o.y).clamp(minLat, maxLat); final double lon = o.x.clamp(minLon, maxLon); if (lat + latitude < minLat) latitude = minLat - lat; if (lat + latitude > maxLat) latitude = maxLat - lat; if (maxLon - minLon < math.pi * 2) { if (lon + longitude < minLon || lon + longitude > maxLon) { longitude = (lon + longitude < minLon ? minLon : maxLon) - lon; // reverse rotation when reaching the boundary if (widget.animSpeed != 0) { if (widget.animReverse) _animateDirection *= -1.0; else _controller.stop(); } } } o.x = lon; o.y = -lat; q = orientationToQuaternion(o); // rotate to longitude zero q *= Quaternion.axisAngle(Vector3(0, 1, 0), -math.pi * 0.5); // rotate around the global Y axis q *= Quaternion.axisAngle(Vector3(0, 1, 0), longitude); // rotate around the local X axis q = Quaternion.axisAngle(Vector3(1, 0, 0), -latitude) * q; o = quaternionToOrientation(q * Quaternion.axisAngle(Vector3(0, 1, 0), math.pi * 0.5)); widget.onViewChanged?.call(degrees(o.x), degrees(-o.y), degrees(o.z)); q.rotate(scene.camera.target..setFrom(Vector3(0, 0, -_radius))); q.rotate(scene.camera.up..setFrom(Vector3(0, 1, 0))); scene.update(); } void _onSceneCreated(Scene scene) { this.scene = scene; if (widget.child != null) { loadImageFromProvider(widget.child.image).then((ui.Image image) { final Mesh mesh = generateSphereMesh(radius: _radius, latSegments: widget.latSegments, lonSegments: widget.lonSegments, texture: image); scene.world.add(Object(name: 'surface', mesh: mesh, backfaceCulling: false)); scene.updateTexture(); scene.camera.near = 1.0; scene.camera.far = _radius + 1.0; scene.camera.fov = 75; scene.camera.zoom = widget.zoom; scene.camera.position.setFrom(Vector3(0, 0, 0.1)); _updateView(); }); } } @override void initState() { super.initState(); latitude = degrees(widget.latitude); longitude = degrees(widget.longitude); switch (widget.sensorControl) { case SensorControl.Orientation: motionSensors.orientationUpdateInterval = Duration.microsecondsPerSecond ~/ 60; _orientationSubscription = motionSensors.orientation.listen((OrientationEvent event) { orientation.setFrom(Vector3(event.yaw, event.pitch, event.roll)); _updateView(); }); break; case SensorControl.AbsoluteOrientation: motionSensors.absoluteOrientationUpdateInterval = Duration.microsecondsPerSecond ~/ 60; _orientationSubscription = motionSensors.absoluteOrientation.listen((AbsoluteOrientationEvent event) { orientation.setFrom(Vector3(event.yaw, event.pitch, event.roll)); _updateView(); }); break; default: } motionSensors.screenOrientation.listen((ScreenOrientationEvent event) { screenOrientation = radians(event.angle); }); _controller = AnimationController(duration: Duration(milliseconds: 60000), vsync: this)..addListener(_updateView); if (widget.sensorControl == SensorControl.None && widget.animSpeed != 0) _controller.repeat(); } @override void dispose() { _orientationSubscription?.cancel(); _screenOrientSubscription?.cancel(); _controller.dispose(); super.dispose(); } @override void didUpdateWidget(Panorama oldWidget) { super.didUpdateWidget(oldWidget); final Object surface = scene.world.find(RegExp('surface')); if (surface == null) return; if (widget.latSegments != oldWidget.latSegments || widget.lonSegments != oldWidget.lonSegments) { surface.mesh = generateSphereMesh(radius: _radius, latSegments: widget.latSegments, lonSegments: widget.lonSegments, texture: surface.mesh.texture); } if (widget.child?.image != oldWidget.child?.image) { loadImageFromProvider(widget.child.image).then((ui.Image image) { surface.mesh.texture = image; surface.mesh.textureRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); scene.updateTexture(); }); } } @override Widget build(BuildContext context) { return widget.interactive ? GestureDetector( onScaleStart: _handleScaleStart, onScaleUpdate: _handleScaleUpdate, child: Cube(interactive: false, onSceneCreated: _onSceneCreated), ) : Cube(interactive: false, onSceneCreated: _onSceneCreated); } } Mesh generateSphereMesh({num radius = 1.0, int latSegments = 16, int lonSegments = 16, ui.Image texture}) { int count = (latSegments + 1) * (lonSegments + 1); List vertices = List(count); List texcoords = List(count); List indices = List(latSegments * lonSegments * 2); int i = 0; for (int y = 0; y <= latSegments; ++y) { final double v = y / latSegments; final double sv = math.sin(v * math.pi); final double cv = math.cos(v * math.pi); for (int x = 0; x <= lonSegments; ++x) { final double u = x / lonSegments; vertices[i] = Vector3(radius * math.cos(u * math.pi * 2.0) * sv, radius * cv, radius * math.sin(u * math.pi * 2.0) * sv); texcoords[i] = Offset(u, 1.0 - v); i++; } } i = 0; for (int y = 0; y < latSegments; ++y) { final int base1 = (lonSegments + 1) * y; final int base2 = (lonSegments + 1) * (y + 1); for (int x = 0; x < lonSegments; ++x) { indices[i++] = Polygon(base1 + x, base1 + x + 1, base2 + x); indices[i++] = Polygon(base1 + x + 1, base2 + x + 1, base2 + x); } } final Mesh mesh = Mesh(vertices: vertices, texcoords: texcoords, indices: indices, texture: texture); return mesh; } /// Get ui.Image from ImageProvider Future loadImageFromProvider(ImageProvider provider) async { final Completer completer = Completer(); final ImageStream imageStream = provider.resolve(ImageConfiguration()); ImageStreamListener listener; listener = ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) { completer.complete(imageInfo.image); imageStream.removeListener(listener); }); imageStream.addListener(listener); return completer.future; } Vector3 quaternionToOrientation(Quaternion q) { // final Matrix4 m = Matrix4.compose(Vector3.zero(), q, Vector3.all(1.0)); // final Vector v = motionSensors.getOrientation(m); // return Vector3(v.z, v.y, v.x); final storage = q.storage; final double x = storage[0]; final double y = storage[1]; final double z = storage[2]; final double w = storage[3]; final double roll = math.atan2(-2 * (x * y - w * z), 1.0 - 2 * (x * x + z * z)); final double pitch = math.asin(2 * (y * z + w * x)); final double yaw = math.atan2(-2 * (x * z - w * y), 1.0 - 2 * (x * x + y * y)); return Vector3(yaw, pitch, roll); } Quaternion orientationToQuaternion(Vector3 v) { final Matrix4 m = Matrix4.identity(); m.rotateZ(v.z); m.rotateX(v.y); m.rotateY(v.x); return Quaternion.fromRotation(m.getRotation()); }