mirror of
https://github.com/Rhinemann/IoT-Systems.git
synced 2026-03-14 20:50:39 +02:00
add: MapView template
This commit is contained in:
parent
93cc8d7378
commit
e32ba94adc
3
MapView/.gitignore
vendored
Normal file
3
MapView/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.idea
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
251
MapView/README.md
Normal file
251
MapView/README.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
## Лабораторна робота №2
|
||||||
|
### Тема
|
||||||
|
|
||||||
|
Візуалізація якості стану дорожнього покриття за допомогою фреймворку Kivy.
|
||||||
|
|
||||||
|
### Мета
|
||||||
|
|
||||||
|
Розробити програму для візуалізації руху машини на дорозі та якості дороги за допомогою даних датчиків.
|
||||||
|
|
||||||
|
### Підготовка робочого середовище, встановлення проекту
|
||||||
|
|
||||||
|
Клонування репозиторію
|
||||||
|
```
|
||||||
|
git clone git@git.comsys.kpi.ua:Diana_Tymoshenko/MapView.git
|
||||||
|
cd MapView
|
||||||
|
```
|
||||||
|
Створення віртуального середовища. Пакет virtualenv повинен бути встановлений в системі!
|
||||||
|
|
||||||
|
`python -m venv ./venv`
|
||||||
|
|
||||||
|
Активація середовища для linux
|
||||||
|
|
||||||
|
`source ./venv/bin/activate`
|
||||||
|
|
||||||
|
Активація середовища для windows
|
||||||
|
|
||||||
|
`venv\Scripts\activate`
|
||||||
|
|
||||||
|
Встановлення необхідних бібліотек
|
||||||
|
|
||||||
|
`pip install kivy mapview`
|
||||||
|
|
||||||
|
### Завдання
|
||||||
|
|
||||||
|
Для початку необхідно отримати дані з датчиків: дані акселерометра знаходяться в файлі data.csv,
|
||||||
|
GPS дані можна згенерувати за допомогою цього [ресурсу](https://www.nmeagen.org/) та записати їх у csv файл.
|
||||||
|
|
||||||
|
Для відображення мапи використовується віджет [Mapview](https://mapview.readthedocs.io/en/1.0.4/) для Kivy.
|
||||||
|
|
||||||
|
Для візуалізації руху машини можна використовувати MapMarker та рухати його відповідно GPS даних.
|
||||||
|
|
||||||
|
Для визначення стану дорожнього покриття потрібно накопичити певну кількість показників акселерометра (наприклад, 100) та
|
||||||
|
проаналізувати їх певним чином на нерівності дороги (наприклад, великі та маленькі ями, бордюр).
|
||||||
|
Для їх позначення на дорозі також використовувати маркери. Зображення для маркерів можна знайти в папці images.
|
||||||
|
|
||||||
|
Для створення та редагування маршруту машини на мапі використовуйте клас LineMapLayer та функцію
|
||||||
|
`add_point()` з файлу lineMapLayer.py. Для додавання лінії на мапу – `mapview.add_layer()` з `mode="scatter"`.
|
||||||
|
|
||||||
|
Щоб створити затримку у відображення руху машини можна використовувати
|
||||||
|
функцію `kivy.clock.Clock.schedule_once()`.
|
||||||
|
|
||||||
|
### Аналіз даних акселерометра
|
||||||
|
|
||||||
|
Будемо використовувати дані тільки по осі z. В стані спокою значення по осі z становить
|
||||||
|
приблизно 16667 одиниць (або 1 g = 9.8 м/с²) (1 од = 0.00006 g).
|
||||||
|
|
||||||
|
Для аналізу нерівностей дороги можна використати функцію `scipy.signal.find_peaks()`, що
|
||||||
|
знаходить локальні максимуми (піки) вхідних даних. Локальні максимуми вказують на наявність
|
||||||
|
бордюрів або лежачих поліцейських, локальні мінімуми - на ями.
|
||||||
|
|
||||||
|
Для налаштування алгоритму знаходження максимумів використовуються такі параметри:
|
||||||
|
- `height`: мінімальна висота піків.
|
||||||
|
- `distance`: мінімальна відстань між піками.
|
||||||
|
- `prominence`: мінімальна висота піку відносно його сусідів (помітність піку).
|
||||||
|
- `width`: мінімальна ширина піку.
|
||||||
|
|
||||||
|
Важливо підібрати оптимальні параметри, щоб відсіяти шум та залишити тільки ті піки, що дійсно відображають
|
||||||
|
якість дорожнього покриття. Для більшої наочності при підбиранні параметрів можна показники акселерометра та
|
||||||
|
результати аналізу відобразити на графіку.
|
||||||
|
|
||||||
|
Для знаходження мінімуму треба відзеркалити дані (помножити на -1) та вказати негативне значення висоти.
|
||||||
|
|
||||||
|
За бажанням можна використовувати будь-який інший спосіб аналізу показників акселерометра.
|
||||||
|
|
||||||
|
**Шаблон основного файлу проєкту**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy_garden.mapview import MapMarker, MapView
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from lineMapLayer import LineMapLayer
|
||||||
|
|
||||||
|
|
||||||
|
class MapViewApp(App):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__()
|
||||||
|
# додати необхідні змінні
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
"""
|
||||||
|
Встановлює необхідні маркери, викликає функцію для оновлення мапи
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update(self, *args):
|
||||||
|
"""
|
||||||
|
Викликається регулярно для оновлення мапи
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_road_quality(self):
|
||||||
|
"""
|
||||||
|
Аналізує дані акселерометра для подальшого визначення
|
||||||
|
та відображення ям та лежачих поліцейських
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update_car_marker(self, point):
|
||||||
|
"""
|
||||||
|
Оновлює відображення маркера машини на мапі
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set_pothole_marker(self, point):
|
||||||
|
"""
|
||||||
|
Встановлює маркер для ями
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set_bump_marker(self, point):
|
||||||
|
"""
|
||||||
|
Встановлює маркер для лежачого поліцейського
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
"""
|
||||||
|
Ініціалізує мапу MapView(zoom, lat, lon)
|
||||||
|
:return: мапу
|
||||||
|
"""
|
||||||
|
self.mapview = MapView()
|
||||||
|
return self.mapview
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
MapViewApp().run()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ідеї для підвищення оцінки
|
||||||
|
|
||||||
|
Замість читання GPS координат з файлу можна використати утиліту Linux `gpsfake`.
|
||||||
|
|
||||||
|
`gpsd` — це програма, яка збирає дані з приймача GPS і надає дані через мережу
|
||||||
|
потенційно декільком клієнтським програмам. Gpsd можна запускати як фонове завдання.
|
||||||
|
gpsd надає сервіс шляхом прив'язки до порту 2947 за замовчуванням.
|
||||||
|
|
||||||
|
Але оскільки в нас немає приймача GPS, його роботу імітує `gpsfake`.
|
||||||
|
Він відкриває PTY (псевдо-TTY), запускає екземпляр gpsd, який вважає,
|
||||||
|
що підлегла сторона PTY є його пристроєм GPS, і передає вміст
|
||||||
|
одного або кількох тестових файлів з координатами до приймача GPS.
|
||||||
|
|
||||||
|
**Встановлення пакетів**:
|
||||||
|
|
||||||
|
`sudo apt install gpsd-clients`
|
||||||
|
|
||||||
|
Щоб додати ці пакети у віртуальне середовище, відкрийте файл конфігурації
|
||||||
|
`venv/pyvenv.cfg` та змініть рядок
|
||||||
|
|
||||||
|
`include-system-site-packages = true`
|
||||||
|
|
||||||
|
В програмі треба додати рядок
|
||||||
|
|
||||||
|
`import gps`
|
||||||
|
|
||||||
|
**Використання gpsfake**
|
||||||
|
|
||||||
|
Команда `gpsfake` запускається в терміналі, але при бажанні можна викликати її із програми
|
||||||
|
за допомогою модулю `subprocess`.
|
||||||
|
|
||||||
|
`gpsfake [OPTIONS] logfile`
|
||||||
|
|
||||||
|
Опції, які можуть знадобитись:
|
||||||
|
- `-P port`: встановлює порт прослуховування.
|
||||||
|
- `-c sec`: встановлює затримку між реченнями в секундах. За замовчуванням дорівнює нулю (без затримки).
|
||||||
|
- `-1`: logfile інтерпретується лише один раз, а не зациклюється.
|
||||||
|
|
||||||
|
`logfile` - це файл, що містить коодринати в будь-якому підтримуваному форматі, включаючи,
|
||||||
|
зокрема, NMEA, SiRF, TSIP або Zodiac. Зокрема за допомогою цього [ресурсу](https://www.nmeagen.org/)
|
||||||
|
можна також згенерувати NMEA файл.
|
||||||
|
|
||||||
|
**Використання gpsd**
|
||||||
|
|
||||||
|
gpsd використовує JSON для зв’язку зі своїми клієнтами. Відповіді містять класи, а їх імена відповідають
|
||||||
|
типу повідомлення NMEA. Важливо зазначити, що ці об’єкти JSON можуть бути неповними,
|
||||||
|
якщо значення не визначено. Це може статися, коли GPS-приймач ще не отримав координати.
|
||||||
|
У такому випадку поле просто опускається. Ви можете знайти повний список команд і більш
|
||||||
|
детальний опис [тут](https://gpsd.gitlab.io/gpsd/gpsd_json.html).
|
||||||
|
|
||||||
|
Більше про gpsd, його методи та приклади використання можна дізнатися [тут](https://gpsd.io/client-howto.html).
|
||||||
|
|
||||||
|
Треба зазначити, що запускати gpsd треба в окремому потоці, бо він не може працювати в одному
|
||||||
|
циклі подій Kivy.
|
||||||
|
|
||||||
|
Нижче наведений приклад коду для використання gpsd.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import gps
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class GpsPoller(threading.Thread):
|
||||||
|
def __init__(self):
|
||||||
|
threading.Thread.__init__(self)
|
||||||
|
self.gpsd_client = gps.gps(port=port, mode=gps.WATCH_ENABLE) # вказати потрібний порт
|
||||||
|
self.current_gps = None # поточні координати
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
def get_gps_data(self):
|
||||||
|
return self.current_gps
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while self.running:
|
||||||
|
response = self.gpsd_client.next()
|
||||||
|
print('response:', response)
|
||||||
|
if response['class'] == 'DEVICE':
|
||||||
|
is_activated = getattr(response, 'activated')
|
||||||
|
if is_activated == 0: # коли logfile з координатами був повністю прочитаний
|
||||||
|
self.running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if response['class'] == 'TPV' and hasattr(response, 'lat'):
|
||||||
|
print("Your position: lat = " + str(response.lat) + ", lon = " + str(response.lon))
|
||||||
|
self.current_gps = [response.lat, response.lon]
|
||||||
|
```
|
||||||
|
|
||||||
|
Для роботи з цим класом треба створити його екземпляр в основному класі програми та розпочати діяльність потоку
|
||||||
|
за допомогою методу `start()`, який викликає метод `run()`. Для отримання координат використовувати метод `get_gps_data()`.
|
||||||
|
|
||||||
|
Зверніть увагу, що перші координати надходять не одразу, тож слідкуйте за тим, яку затримку
|
||||||
|
треба поставити, щоб перша відповідь містила поля lat та lon. Також якщо при виклику команди `gpsfake`
|
||||||
|
ви використовуєте затримку між реченнями в 1 секунду, то між кожним зчитуванням координат від gpsd
|
||||||
|
треба поставити затримку в 3 секунди, бо для однієї пари координат використовується 3 речення
|
||||||
|
з logfile.
|
||||||
|
|
||||||
|
Якщо при запуску програми виникає така помилка:
|
||||||
|
```
|
||||||
|
gpsd:ERROR: SER: device open of /dev/pts/4 failed: Permission denied - retrying read-only
|
||||||
|
gpsd:ERROR: SER: read-only device open of /dev/pts/4 failed: Permission denied
|
||||||
|
gpsd:ERROR: /dev/pts/4: device activation failed, freeing device.
|
||||||
|
```
|
||||||
|
Треба змінити режим профілю безпеки AppArmor для gpsd з режиму enforced на режим complain:
|
||||||
|
```
|
||||||
|
sudo apt install apparmor-utils
|
||||||
|
sudo aa-complain /usr/sbin/gpsd
|
||||||
|
sudo apparmor-status # для перевірки режиму
|
||||||
|
```
|
||||||
|
|
||||||
|
### Корисні посилання:
|
||||||
|
|
||||||
|
- [Kivy](https://kivy.org/doc/stable/)
|
||||||
|
- [Mapview](https://mapview.readthedocs.io/en/1.0.4/)
|
||||||
|
- [scipy.signal.find_peaks](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.find_peaks.html)
|
||||||
|
- [gpsfake](https://gpsd.gitlab.io/gpsd/gpsfake.html)
|
||||||
|
- [gpsd](https://gpsd.io/gpsd.html)
|
||||||
|
- [threading](https://docs.python.org/uk/3/library/threading.html)
|
||||||
1
MapView/data.csv
Normal file
1
MapView/data.csv
Normal file
File diff suppressed because one or more lines are too long
BIN
MapView/images/bump.png
Normal file
BIN
MapView/images/bump.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
MapView/images/car.png
Normal file
BIN
MapView/images/car.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
MapView/images/pothole.png
Normal file
BIN
MapView/images/pothole.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
56
MapView/lab2.py
Normal file
56
MapView/lab2.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from kivy.app import App
|
||||||
|
from kivy_garden.mapview import MapMarker, MapView
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from lineMapLayer import LineMapLayer
|
||||||
|
|
||||||
|
|
||||||
|
class MapViewApp(App):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__()
|
||||||
|
# додати необхідні змінні
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
"""
|
||||||
|
Встановлює необхідні маркери, викликає функцію для оновлення мапи
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update(self, *args):
|
||||||
|
"""
|
||||||
|
Викликається регулярно для оновлення мапи
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_road_quality(self):
|
||||||
|
"""
|
||||||
|
Аналізує дані акселерометра для подальшого визначення
|
||||||
|
та відображення ям та лежачих поліцейських
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update_car_marker(self, point):
|
||||||
|
"""
|
||||||
|
Оновлює відображення маркера машини на мапі
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set_pothole_marker(self, point):
|
||||||
|
"""
|
||||||
|
Встановлює маркер для ями
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def set_bump_marker(self, point):
|
||||||
|
"""
|
||||||
|
Встановлює маркер для лежачого поліцейського
|
||||||
|
:param point: GPS координати
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
"""
|
||||||
|
Ініціалізує мапу MapView(zoom, lat, lon)
|
||||||
|
:return: мапу
|
||||||
|
"""
|
||||||
|
self.mapview = MapView()
|
||||||
|
return self.mapview
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
MapViewApp().run()
|
||||||
147
MapView/lineMapLayer.py
Normal file
147
MapView/lineMapLayer.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
from kivy_garden.mapview import MapLayer, MapMarker
|
||||||
|
from kivy.graphics import Color, Line
|
||||||
|
from kivy.graphics.context_instructions import Translate, Scale, PushMatrix, PopMatrix
|
||||||
|
from kivy_garden.mapview.utils import clamp
|
||||||
|
from kivy_garden.mapview.constants import (MIN_LONGITUDE, MAX_LONGITUDE, MIN_LATITUDE, MAX_LATITUDE)
|
||||||
|
from math import radians, log, tan, cos, pi
|
||||||
|
|
||||||
|
|
||||||
|
class LineMapLayer(MapLayer):
|
||||||
|
def __init__(self, coordinates=None, color=[0, 0, 1, 1], width=2, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
# if coordinates is None:
|
||||||
|
# coordinates = [[0, 0], [0, 0]]
|
||||||
|
self._coordinates = coordinates
|
||||||
|
self.color = color
|
||||||
|
self._line_points = None
|
||||||
|
self._line_points_offset = (0, 0)
|
||||||
|
self.zoom = 0
|
||||||
|
self.lon = 0
|
||||||
|
self.lat = 0
|
||||||
|
self.ms = 0
|
||||||
|
self._width = width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def coordinates(self):
|
||||||
|
return self._coordinates
|
||||||
|
|
||||||
|
@coordinates.setter
|
||||||
|
def coordinates(self, coordinates):
|
||||||
|
self._coordinates = coordinates
|
||||||
|
self.invalidate_line_points()
|
||||||
|
self.clear_and_redraw()
|
||||||
|
|
||||||
|
def add_point(self, point):
|
||||||
|
if self._coordinates is None:
|
||||||
|
#self._coordinates = [point]
|
||||||
|
self._coordinates = []
|
||||||
|
self._coordinates.append(point)
|
||||||
|
# self._coordinates = [self._coordinates[-1], point]
|
||||||
|
self.invalidate_line_points()
|
||||||
|
self.clear_and_redraw()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_points(self):
|
||||||
|
if self._line_points is None:
|
||||||
|
self.calc_line_points()
|
||||||
|
return self._line_points
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_points_offset(self):
|
||||||
|
if self._line_points is None:
|
||||||
|
self.calc_line_points()
|
||||||
|
return self._line_points_offset
|
||||||
|
|
||||||
|
def calc_line_points(self):
|
||||||
|
# Offset all points by the coordinates of the first point,
|
||||||
|
# to keep coordinates closer to zero.
|
||||||
|
# (and therefore avoid some float precision issues when drawing lines)
|
||||||
|
self._line_points_offset = (self.get_x(self.coordinates[0][1]),
|
||||||
|
self.get_y(self.coordinates[0][0]))
|
||||||
|
# Since lat is not a linear transform we must compute manually
|
||||||
|
self._line_points = [(self.get_x(lon) - self._line_points_offset[0],
|
||||||
|
self.get_y(lat) - self._line_points_offset[1])
|
||||||
|
for lat, lon in self.coordinates]
|
||||||
|
|
||||||
|
def invalidate_line_points(self):
|
||||||
|
self._line_points = None
|
||||||
|
self._line_points_offset = (0, 0)
|
||||||
|
|
||||||
|
def get_x(self, lon):
|
||||||
|
"""Get the x position on the map using this map source's projection
|
||||||
|
(0, 0) is located at the top left.
|
||||||
|
"""
|
||||||
|
return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) * self.ms / 360.0
|
||||||
|
|
||||||
|
def get_y(self, lat):
|
||||||
|
"""Get the y position on the map using this map source's projection
|
||||||
|
(0, 0) is located at the top left.
|
||||||
|
"""
|
||||||
|
lat = radians(clamp(-lat, MIN_LATITUDE, MAX_LATITUDE))
|
||||||
|
return (1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) * self.ms / 2.0
|
||||||
|
|
||||||
|
# Function called when the MapView is moved
|
||||||
|
def reposition(self):
|
||||||
|
map_view = self.parent
|
||||||
|
|
||||||
|
# Must redraw when the zoom changes
|
||||||
|
# as the scatter transform resets for the new tiles
|
||||||
|
if self.zoom != map_view.zoom or \
|
||||||
|
self.lon != round(map_view.lon, 7) or \
|
||||||
|
self.lat != round(map_view.lat, 7):
|
||||||
|
map_source = map_view.map_source
|
||||||
|
self.ms = pow(2.0, map_view.zoom) * map_source.dp_tile_size
|
||||||
|
self.invalidate_line_points()
|
||||||
|
self.clear_and_redraw()
|
||||||
|
|
||||||
|
def clear_and_redraw(self, *args):
|
||||||
|
with self.canvas:
|
||||||
|
# Clear old line
|
||||||
|
self.canvas.clear()
|
||||||
|
|
||||||
|
self._draw_line()
|
||||||
|
|
||||||
|
def _draw_line(self, *args):
|
||||||
|
if self._coordinates is None:
|
||||||
|
return
|
||||||
|
map_view = self.parent
|
||||||
|
self.zoom = map_view.zoom
|
||||||
|
self.lon = map_view.lon
|
||||||
|
self.lat = map_view.lat
|
||||||
|
|
||||||
|
# When zooming we must undo the current scatter transform
|
||||||
|
# or the animation distorts it
|
||||||
|
scatter = map_view._scatter
|
||||||
|
sx, sy, ss = scatter.x, scatter.y, scatter.scale
|
||||||
|
|
||||||
|
# Account for map source tile size and map view zoom
|
||||||
|
vx, vy, vs = map_view.viewport_pos[0], map_view.viewport_pos[1], map_view.scale
|
||||||
|
|
||||||
|
with self.canvas:
|
||||||
|
self.opacity = 0.5
|
||||||
|
# Save the current coordinate space context
|
||||||
|
PushMatrix()
|
||||||
|
|
||||||
|
# Offset by the MapView's position in the window (always 0,0 ?)
|
||||||
|
Translate(*map_view.pos)
|
||||||
|
|
||||||
|
# Undo the scatter animation transform
|
||||||
|
Scale(1 / ss, 1 / ss, 1)
|
||||||
|
Translate(-sx, -sy)
|
||||||
|
|
||||||
|
# Apply the get window xy from transforms
|
||||||
|
Scale(vs, vs, 1)
|
||||||
|
Translate(-vx, -vy)
|
||||||
|
|
||||||
|
# Apply what we can factor out of the mapsource long, lat to x, y conversion
|
||||||
|
Translate(self.ms / 2, 0)
|
||||||
|
|
||||||
|
# Translate by the offset of the line points
|
||||||
|
# (this keeps the points closer to the origin)
|
||||||
|
Translate(*self.line_points_offset)
|
||||||
|
|
||||||
|
Color(*self.color)
|
||||||
|
Line(points=self.line_points, width=self._width)
|
||||||
|
|
||||||
|
# Retrieve the last saved coordinate space context
|
||||||
|
PopMatrix()
|
||||||
Loading…
x
Reference in New Issue
Block a user