laserscan_plugin.cpp
Go to the documentation of this file.
1 // *****************************************************************************
2 //
3 // Copyright (c) 2015, Southwest Research Institute® (SwRI®)
4 // All rights reserved.
5 //
6 // Redistribution and use in source and binary forms, with or without
7 // modification, are permitted provided that the following conditions are met:
8 // * Redistributions of source code must retain the above copyright
9 // notice, this list of conditions and the following disclaimer.
10 // * Redistributions in binary form must reproduce the above copyright
11 // notice, this list of conditions and the following disclaimer in the
12 // documentation and/or other materials provided with the distribution.
13 // * Neither the name of Southwest Research Institute® (SwRI®) nor the
14 // names of its contributors may be used to endorse or promote products
15 // derived from this software without specific prior written permission.
16 //
17 // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18 // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19 // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20 // ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
21 // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
22 // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
23 // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
24 // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25 // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26 // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 //
28 // *****************************************************************************
29 
31 
32 // C++ standard libraries
33 #include <cmath>
34 #include <cstdio>
35 #include <algorithm>
36 #include <vector>
37 
38 // Boost libraries
39 #include <boost/algorithm/string.hpp>
40 
41 // QT libraries
42 #include <QColorDialog>
43 #include <QDialog>
44 #include <QGLWidget>
45 
46 // QT Autogenerated
47 #include "ui_topic_select.h"
48 
49 // ROS libraries
50 #include <ros/master.h>
53 
55 
56 // Declare plugin
59 
60 namespace mapviz_plugins
61 {
63  config_widget_(new QWidget()),
64  topic_(""),
65  alpha_(1.0),
66  min_value_(0.0),
67  max_value_(100.0),
68  point_size_(3),
69  prev_ranges_size_(0),
70  prev_angle_min_(0.0),
71  prev_increment_(0.0)
72  {
73  ui_.setupUi(config_widget_);
74 
75  // Set background white
76  QPalette p(config_widget_->palette());
77  p.setColor(QPalette::Background, Qt::white);
78  config_widget_->setPalette(p);
79 
80  // Set status text red
81  QPalette p3(ui_.status->palette());
82  p3.setColor(QPalette::Text, Qt::red);
83  ui_.status->setPalette(p3);
84 
85  // Initialize color selector colors
86  ui_.min_color->setColor(Qt::white);
87  ui_.max_color->setColor(Qt::black);
88 
89  // Set color transformer choices
90  ui_.color_transformer->addItem(QString("Flat Color"), QVariant(0));
91  ui_.color_transformer->addItem(QString("Intensity"), QVariant(1));
92  ui_.color_transformer->addItem(QString("Range"), QVariant(2));
93  ui_.color_transformer->addItem(QString("X Axis"), QVariant(3));
94  ui_.color_transformer->addItem(QString("Y Axis"), QVariant(4));
95  ui_.color_transformer->addItem(QString("Z Axis"), QVariant(5));
96 
97  QObject::connect(ui_.selecttopic,
98  SIGNAL(clicked()),
99  this,
100  SLOT(SelectTopic()));
101  QObject::connect(ui_.topic,
102  SIGNAL(editingFinished()),
103  this,
104  SLOT(TopicEdited()));
105  QObject::connect(ui_.alpha,
106  SIGNAL(valueChanged(double)),
107  this,
108  SLOT(AlphaEdited(double)));
109  QObject::connect(ui_.color_transformer,
110  SIGNAL(currentIndexChanged(int)),
111  this,
112  SLOT(ColorTransformerChanged(int)));
113  QObject::connect(ui_.max_color,
114  SIGNAL(colorEdited(const QColor &)),
115  this,
116  SLOT(UpdateColors()));
117  QObject::connect(ui_.min_color,
118  SIGNAL(colorEdited(const QColor &)),
119  this,
120  SLOT(UpdateColors()));
121  QObject::connect(ui_.minValue,
122  SIGNAL(valueChanged(double)),
123  this,
124  SLOT(MinValueChanged(double)));
125  QObject::connect(ui_.maxValue,
126  SIGNAL(valueChanged(double)),
127  this,
128  SLOT(MaxValueChanged(double)));
129  QObject::connect(ui_.bufferSize,
130  SIGNAL(valueChanged(int)),
131  this,
132  SLOT(BufferSizeChanged(int)));
133  QObject::connect(ui_.pointSize,
134  SIGNAL(valueChanged(int)),
135  this,
136  SLOT(PointSizeChanged(int)));
137  QObject::connect(ui_.use_rainbow,
138  SIGNAL(stateChanged(int)),
139  this,
140  SLOT(UseRainbowChanged(int)));
141 
142  QObject::connect(ui_.max_color,
143  SIGNAL(colorEdited(const QColor &)),
144  this,
145  SLOT(DrawIcon()));
146  QObject::connect(ui_.min_color,
147  SIGNAL(colorEdited(const QColor &)),
148  this,
149  SLOT(DrawIcon()));
150  QObject::connect(this,
151  SIGNAL(TargetFrameChanged(const std::string&)),
152  this,
153  SLOT(ResetTransformedScans()));
154 
155  PrintInfo("Constructed LaserScanPlugin");
156  }
157 
159  {
160  }
161 
163  {
164  ROS_DEBUG("LaserScan::ClearHistory()");
165  scans_.clear();
166  }
167 
169  {
170  if (icon_)
171  {
172  QPixmap icon(16, 16);
173  icon.fill(Qt::transparent);
174 
175  QPainter painter(&icon);
176  painter.setRenderHint(QPainter::Antialiasing, true);
177 
178  QPen pen;
179  pen.setWidth(4);
180  pen.setCapStyle(Qt::RoundCap);
181 
182  pen.setColor(ui_.min_color->color());
183  painter.setPen(pen);
184  painter.drawPoint(2, 13);
185 
186  pen.setColor(ui_.min_color->color());
187  painter.setPen(pen);
188  painter.drawPoint(4, 6);
189 
190  pen.setColor(ui_.max_color->color());
191  painter.setPen(pen);
192  painter.drawPoint(12, 9);
193 
194  pen.setColor(ui_.max_color->color());
195  painter.setPen(pen);
196  painter.drawPoint(13, 2);
197 
198  icon_->SetPixmap(icon);
199  }
200  }
201 
203  {
204  for (Scan& scan: scans_)
205  {
206  scan.transformed = false;
207  }
208  }
209 
211  bool has_intensity)
212  {
213  double val;
214  int color_transformer = ui_.color_transformer->currentIndex();
215  if (color_transformer == COLOR_RANGE)
216  {
217  val = point.range;
218  }
219  else if (color_transformer == COLOR_INTENSITY && has_intensity)
220  {
221  val = point.intensity;
222  }
223  else if (color_transformer == COLOR_X)
224  {
225  val = point.point.x();
226  }
227  else if (color_transformer == COLOR_Y)
228  {
229  val = point.point.y();
230  }
231  else if (color_transformer == COLOR_Z)
232  {
233  val = point.transformed_point.z();
234  }
235  else // No intensity or (color_transformer == COLOR_FLAT)
236  {
237  return ui_.min_color->color();
238  }
239  if (max_value_ > min_value_)
240  val = (val - min_value_) / (max_value_ - min_value_);
241  val = std::max(0.0, std::min(val, 1.0));
242  if (ui_.use_rainbow->isChecked())
243  {
244  // Hue Interpolation
245  int hue = static_cast<int>(val * 255);
246  return QColor::fromHsl(hue, 255, 127, 255);
247  }
248  else
249  {
250  const QColor min_color = ui_.min_color->color();
251  const QColor max_color = ui_.max_color->color();
252  // RGB Interpolation
253  int red, green, blue;
254  red = static_cast<int>(val * max_color.red() + ((1.0 - val) * min_color.red()));
255  green = static_cast<int>(val * max_color.green() + ((1.0 - val) * min_color.green()));
256  blue = static_cast<int>(val * max_color.blue() + ((1.0 - val) * min_color.blue()));
257  return QColor(red, green, blue, 255);
258  }
259  }
260 
262  {
263  std::deque<Scan>::iterator scan_it = scans_.begin();
264  for (; scan_it != scans_.end(); ++scan_it)
265  {
266  std::vector<StampedPoint>::iterator point_it = scan_it->points.begin();
267  for (; point_it != scan_it->points.end(); point_it++)
268  {
269  point_it->color = CalculateColor(*point_it, scan_it->has_intensity);
270  }
271  }
272  }
273 
275  {
277  "sensor_msgs/LaserScan");
278 
279  if (!topic.name.empty())
280  {
281  ui_.topic->setText(QString::fromStdString(topic.name));
282  TopicEdited();
283  }
284  }
285 
287  {
288  std::string topic = ui_.topic->text().trimmed().toStdString();
289  if (topic != topic_)
290  {
291  initialized_ = false;
292  scans_.clear();
293  has_message_ = false;
294  PrintWarning("No messages received.");
295 
297 
298  topic_ = topic;
299  if (!topic.empty())
300  {
302  100,
304  this);
305 
306  ROS_INFO("Subscribing to %s", topic_.c_str());
307  }
308  }
309  }
310 
312  {
313  min_value_ = value;
314  UpdateColors();
315  }
316 
318  {
319  max_value_ = value;
320  UpdateColors();
321  }
322 
324  {
325  buffer_size_ = static_cast<size_t>(value);
326 
327  if (buffer_size_ > 0)
328  {
329  while (scans_.size() > buffer_size_)
330  {
331  scans_.pop_front();
332  }
333  }
334  }
335 
337  {
338  point_size_ = static_cast<size_t>(value);
339  }
340 
341  void LaserScanPlugin::updatePreComputedTriginometic(const sensor_msgs::LaserScanConstPtr& msg)
342  {
343  if( msg->ranges.size() != prev_ranges_size_ ||
344  msg->angle_min != prev_angle_min_ ||
345  msg->angle_increment != prev_increment_ )
346  {
347  prev_ranges_size_ = msg->ranges.size();
348  prev_angle_min_ = msg->angle_min;
349  prev_increment_ = msg->angle_increment;
350 
351  precomputed_cos_.resize( msg->ranges.size() );
352  precomputed_sin_.resize( msg->ranges.size() );
353 
354  for (size_t i = 0; i < msg->ranges.size(); i++)
355  {
356  double angle = msg->angle_min + msg->angle_increment * i;
357  precomputed_cos_[i] = cos(angle);
358  precomputed_sin_[i] = sin(angle);
359  }
360  }
361  }
362 
364  {
365  bool was_using_latest_transforms = this->use_latest_transforms_;
366  //Try first with use_latest_transforms_ = false
367  this->use_latest_transforms_ = false;
368  bool has_tranform = GetTransform(scan.source_frame_, scan.stamp, transform);
369  if( !has_tranform && was_using_latest_transforms)
370  {
371  //If failed use_latest_transforms_ = true
372  this->use_latest_transforms_ = true;
373  has_tranform = GetTransform(scan.source_frame_, scan.stamp, transform);
374  }
375 
376  this->use_latest_transforms_ = was_using_latest_transforms;
377  return has_tranform;
378  }
379 
380  void LaserScanPlugin::laserScanCallback(const sensor_msgs::LaserScanConstPtr& msg)
381  {
382  if (!has_message_)
383  {
384  initialized_ = true;
385  has_message_ = true;
386  }
387 
388  // Note that unlike some plugins, this one does not store nor rely on the
389  // source_frame_ member variable. This one can potentially store many
390  // messages with different source frames, so we need to store and transform
391  // them individually.
392 
393  Scan scan;
394  scan.stamp = msg->header.stamp;
395  scan.color = QColor::fromRgbF(1.0f, 0.0f, 0.0f, 1.0f);
396  scan.source_frame_ = msg->header.frame_id;
397  scan.has_intensity = !msg->intensities.empty();
398  scan.points.reserve( msg->ranges.size() );
399 
400  double x, y;
402 
404  scan.transformed = GetScanTransform(scan, transform);
405 
406  for (size_t i = 0; i < msg->ranges.size(); i++)
407  {
408  // Discard the point if it's out of range
409  if (msg->ranges[i] > msg->range_max || msg->ranges[i] < msg->range_min)
410  {
411  continue;
412  }
413  StampedPoint point;
414  x = precomputed_cos_[i] * msg->ranges[i];
415  y = precomputed_sin_[i] * msg->ranges[i];
416  point.point = tf::Point(x, y, 0.0f);
417  point.range = msg->ranges[i];
418  if (i < msg->intensities.size())
419  point.intensity = msg->intensities[i];
420 
421  if (scan.transformed)
422  {
423  point.transformed_point = transform * point.point;
424  }
425  point.color = CalculateColor(point, scan.has_intensity);
426  scan.points.push_back(point);
427  }
428  scans_.push_back(scan);
429 
430  // If there are more items in the scan buffer than buffer_size_, remove them
431  if (buffer_size_ > 0)
432  {
433  while (scans_.size() > buffer_size_)
434  {
435  scans_.pop_front();
436  }
437  }
438  }
439 
440  void LaserScanPlugin::PrintError(const std::string& message)
441  {
442  PrintErrorHelper(ui_.status, message);
443  }
444 
445  void LaserScanPlugin::PrintInfo(const std::string& message)
446  {
447  PrintInfoHelper(ui_.status, message);
448  }
449 
450  void LaserScanPlugin::PrintWarning(const std::string& message)
451  {
452  PrintWarningHelper(ui_.status, message);
453  }
454 
455  QWidget* LaserScanPlugin::GetConfigWidget(QWidget* parent)
456  {
457  config_widget_->setParent(parent);
458 
459  return config_widget_;
460  }
461 
462  bool LaserScanPlugin::Initialize(QGLWidget* canvas)
463  {
464  canvas_ = canvas;
465 
466  DrawIcon();
467 
468  return true;
469  }
470 
471  void LaserScanPlugin::Draw(double x, double y, double scale)
472  {
473  glPointSize(point_size_);
474  glBegin(GL_POINTS);
475 
476  std::deque<Scan>::const_iterator scan_it = scans_.begin();
477  while (scan_it != scans_.end())
478  {
479  if (scan_it->transformed)
480  {
481  std::vector<StampedPoint>::const_iterator point_it = scan_it->points.begin();
482  for (; point_it != scan_it->points.end(); ++point_it)
483  {
484  glColor4d(
485  point_it->color.redF(),
486  point_it->color.greenF(),
487  point_it->color.blueF(),
488  alpha_);
489  glVertex2d(
490  point_it->transformed_point.getX(),
491  point_it->transformed_point.getY());
492  }
493  }
494  ++scan_it;
495  }
496 
497  glEnd();
498 
499  PrintInfo("OK");
500  }
501 
503  {
504  if (check_state == Qt::Checked)
505  {
506  ui_.max_color->setVisible(false);
507  ui_.min_color->setVisible(false);
508  ui_.maxColorLabel->setVisible(false);
509  ui_.minColorLabel->setVisible(false);
510  }
511  else
512  {
513  ui_.max_color->setVisible(true);
514  ui_.min_color->setVisible(true);
515  ui_.maxColorLabel->setVisible(true);
516  ui_.minColorLabel->setVisible(true);
517  }
518  UpdateColors();
519  }
520 
522  {
523  std::deque<Scan>::iterator scan_it = scans_.begin();
524  for (; scan_it != scans_.end(); ++scan_it)
525  {
526  Scan& scan = *scan_it;
527 
528  if( !scan.transformed )
529  {
531 
532  if ( GetScanTransform( scan, transform) )
533  {
534  scan.transformed = true;
535  std::vector<StampedPoint>::iterator point_it = scan.points.begin();
536  for (; point_it != scan.points.end(); ++point_it)
537  {
538  point_it->transformed_point = transform * point_it->point;
539  }
540  }
541  else{
542  PrintError("No transform between " + scan.source_frame_ + " and " + target_frame_);
543  }
544  }
545  }
546  // Z color is based on transformed color, so it is dependent on the
547  // transform
548  if (ui_.color_transformer->currentIndex() == COLOR_Z)
549  {
550  UpdateColors();
551  }
552  }
553 
554  void LaserScanPlugin::LoadConfig(const YAML::Node& node,
555  const std::string& path)
556  {
557  if (node["topic"])
558  {
559  std::string topic;
560  node["topic"] >> topic;
561  ui_.topic->setText(boost::trim_copy(topic).c_str());
562  TopicEdited();
563  }
564 
565  if (node["size"])
566  {
567  node["size"] >> point_size_;
568  ui_.pointSize->setValue(static_cast<int>(point_size_));
569  }
570 
571  if (node["buffer_size"])
572  {
573  node["buffer_size"] >> buffer_size_;
574  ui_.bufferSize->setValue(static_cast<int>(buffer_size_));
575  }
576 
577  if (node["color_transformer"])
578  {
579  std::string color_transformer;
580  node["color_transformer"] >> color_transformer;
581  if (color_transformer == "Intensity")
582  ui_.color_transformer->setCurrentIndex(COLOR_INTENSITY);
583  else if (color_transformer == "Range")
584  ui_.color_transformer->setCurrentIndex(COLOR_RANGE);
585  else if (color_transformer == "X Axis")
586  ui_.color_transformer->setCurrentIndex(COLOR_X);
587  else if (color_transformer == "Y Axis")
588  ui_.color_transformer->setCurrentIndex(COLOR_Y);
589  else if (color_transformer == "Z Axis")
590  ui_.color_transformer->setCurrentIndex(COLOR_Z);
591  else
592  ui_.color_transformer->setCurrentIndex(COLOR_FLAT);
593  }
594 
595  if (node["min_color"])
596  {
597  std::string min_color_str;
598  node["min_color"] >> min_color_str;
599  ui_.min_color->setColor(QColor(min_color_str.c_str()));
600  }
601 
602  if (node["max_color"])
603  {
604  std::string max_color_str;
605  node["max_color"] >> max_color_str;
606  ui_.max_color->setColor(QColor(max_color_str.c_str()));
607  }
608 
609  if (node["value_min"])
610  {
611  node["value_min"] >> min_value_;
612  ui_.minValue->setValue(min_value_);
613  }
614 
615  if (node["max_value"])
616  {
617  node["value_max"] >> max_value_;
618  ui_.maxValue->setValue(max_value_);
619  }
620 
621  if (node["alpha"])
622  {
623  node["alpha"] >> alpha_;
624  ui_.alpha->setValue(alpha_);
625  }
626 
627  if (node["use_rainbow"])
628  {
629  bool use_rainbow;
630  node["use_rainbow"] >> use_rainbow;
631  ui_.use_rainbow->setChecked(use_rainbow);
632  }
633 
634  // UseRainbowChanged must be called *before* ColorTransformerChanged
635  UseRainbowChanged(ui_.use_rainbow->checkState());
636  // ColorTransformerChanged will also update colors of all points
637  ColorTransformerChanged(ui_.color_transformer->currentIndex());
638  }
639 
641  {
642  ROS_DEBUG("Color transformer changed to %d", index);
643  switch (index)
644  {
645  case COLOR_FLAT:
646  ui_.min_color->setVisible(true);
647  ui_.max_color->setVisible(false);
648  ui_.maxColorLabel->setVisible(false);
649  ui_.minColorLabel->setVisible(false);
650  ui_.minValueLabel->setVisible(false);
651  ui_.maxValueLabel->setVisible(false);
652  ui_.minValue->setVisible(false);
653  ui_.maxValue->setVisible(false);
654  ui_.use_rainbow->setVisible(false);
655  break;
656  case COLOR_INTENSITY: // Intensity
657  case COLOR_RANGE: // Range
658  case COLOR_X: // X Axis
659  case COLOR_Y: // Y Axis
660  case COLOR_Z: // Z axis
661  default:
662  ui_.min_color->setVisible(!ui_.use_rainbow->isChecked());
663  ui_.max_color->setVisible(!ui_.use_rainbow->isChecked());
664  ui_.maxColorLabel->setVisible(!ui_.use_rainbow->isChecked());
665  ui_.minColorLabel->setVisible(!ui_.use_rainbow->isChecked());
666  ui_.minValueLabel->setVisible(true);
667  ui_.maxValueLabel->setVisible(true);
668  ui_.minValue->setVisible(true);
669  ui_.maxValue->setVisible(true);
670  ui_.use_rainbow->setVisible(true);
671  break;
672  }
673  UpdateColors();
674  }
675 
680  {
681  alpha_ = std::max(0.0f, std::min((float)val, 1.0f));
682  }
683 
684  void LaserScanPlugin::SaveConfig(YAML::Emitter& emitter,
685  const std::string& path)
686  {
687  emitter << YAML::Key << "topic" <<
688  YAML::Value << boost::trim_copy(ui_.topic->text().toStdString());
689  emitter << YAML::Key << "size" <<
690  YAML::Value << ui_.pointSize->value();
691  emitter << YAML::Key << "buffer_size" <<
692  YAML::Value << ui_.bufferSize->value();
693  emitter << YAML::Key << "alpha" <<
694  YAML::Value << alpha_;
695  emitter << YAML::Key << "color_transformer" <<
696  YAML::Value << ui_.color_transformer->currentText().toStdString();
697  emitter << YAML::Key << "min_color" <<
698  YAML::Value << ui_.min_color->color().name().toStdString();
699  emitter << YAML::Key << "max_color" <<
700  YAML::Value << ui_.max_color->color().name().toStdString();
701  emitter << YAML::Key << "value_min" <<
702  YAML::Value << ui_.minValue->text().toDouble();
703  emitter << YAML::Key << "value_max" <<
704  YAML::Value << ui_.maxValue->text().toDouble();
705  emitter << YAML::Key << "use_rainbow" <<
706  YAML::Value << ui_.use_rainbow->isChecked();
707  }
708 }
709 
bool GetTransform(const ros::Time &stamp, swri_transform_util::Transform &transform, bool use_latest_transforms=true)
static ros::master::TopicInfo selectTopic(const std::string &datatype, QWidget *parent=0)
void laserScanCallback(const sensor_msgs::LaserScanConstPtr &scan)
IconWidget * icon_
ros::NodeHandle node_
std::vector< StampedPoint > points
f
Subscriber subscribe(const std::string &topic, uint32_t queue_size, void(T::*fp)(M), T *obj, const TransportHints &transport_hints=TransportHints())
static void PrintWarningHelper(QLabel *status_label, const std::string &message, double throttle=0.0)
static void PrintErrorHelper(QLabel *status_label, const std::string &message, double throttle=0.0)
void PrintWarning(const std::string &message)
std::string target_frame_
tf::Vector3 Point
static void PrintInfoHelper(QLabel *status_label, const std::string &message, double throttle=0.0)
QWidget * GetConfigWidget(QWidget *parent)
std::vector< double > precomputed_cos_
TFSIMD_FORCE_INLINE tfScalar angle(const Quaternion &q1, const Quaternion &q2)
void SetPixmap(QPixmap pixmap)
void Draw(double x, double y, double scale)
#define ROS_INFO(...)
std::vector< double > precomputed_sin_
void TargetFrameChanged(const std::string &target_frame)
void LoadConfig(const YAML::Node &node, const std::string &path)
QColor CalculateColor(const StampedPoint &point, bool has_intensity)
void PrintInfo(const std::string &message)
void PrintError(const std::string &message)
void transform(marti_nav_msgs::Path &path, const swri_transform_util::Transform &transform, const std::string &target_frame)
bool Initialize(QGLWidget *canvas)
void updatePreComputedTriginometic(const sensor_msgs::LaserScanConstPtr &msg)
void SaveConfig(YAML::Emitter &emitter, const std::string &path)
#define PLUGINLIB_EXPORT_CLASS(class_type, base_class_type)
void UseRainbowChanged(int check_state)
bool GetScanTransform(const Scan &scan, swri_transform_util::Transform &transform)
#define ROS_DEBUG(...)


mapviz_plugins
Author(s): Marc Alban
autogenerated on Fri Dec 16 2022 03:59:33