A handy .NET class to help manage long operations in AutoCAD

 

This post was almost called "Generating Koch fractals in AutoCAD using .NET - Part 3", following on from Parts 1 & 2 of the series. But by the time I'd completed the code, I realised it to be of more general appeal and decided to provide it with a more representative title.

I started off by adding a progress meter and an escape key handler to the code in the last post. Then, while refactoring the code, I decided to encapsulate the functionality in a standalone class that could be dropped into pretty much any AutoCAD .NET project (although I've implemented it in C#, as usual).

So what we have is a new class called LongOperationManager, which does the following:

  • Displays and updates a progress meter (at the bottom left of AutoCAD's window)
    • Allowing you to set an arbitrary message and total number of operations
  • Listens for "escape" in case the user wants to interrupt the current operation

Here's the class implementation:

public class LongOperationManager :

  IDisposable, System.Windows.Forms.IMessageFilter

{

  // The message code corresponding to a keypress

  const int WM_KEYDOWN = 0x0100;


  // The number of times to update the progress meter

  // (for some reason you need 600 to tick through

  //  for each percent)

  const int progressMeterIncrements = 600;


  // Internal members for metering progress

  private ProgressMeter pm;

  private long updateIncrement;

  private long currentInc;


  // External flag for checking cancelled status

  public bool cancelled = false;


  // Constructor


  public LongOperationManager(string message)

  {

    System.Windows.Forms.Application.

      AddMessageFilter(this);

    pm = new ProgressMeter();

    pm.Start(message);

    pm.SetLimit(progressMeterIncrements);

    currentInc = 0;

  }


  // System.IDisposable.Dispose


  public void Dispose()

  {

    pm.Stop();

    pm.Dispose();

    System.Windows.Forms.Application.

      RemoveMessageFilter(this);

  }


  // Set the total number of operations


  public void SetTotalOperations(long totalOps)

  {

    // We really just care about when we need

    // to update the timer

    updateIncrement =

      (totalOps > progressMeterIncrements ?

        totalOps / progressMeterIncrements :

        totalOps

      );

  }


  // This function is called whenever an operation

  // is performed


  public bool Tick()

  {

    if (++currentInc == updateIncrement)

    {

      pm.MeterProgress();

      currentInc = 0;

      System.Windows.Forms.Application.DoEvents();

    }

    // Check whether the filter has set the flag

    if (cancelled)

      pm.Stop();


    return !cancelled;

  }


  // The message filter callback


  public bool PreFilterMessage(

    ref System.Windows.Forms.Message m

  )

  {

    if (m.Msg == WM_KEYDOWN)

    {

      // Check for the Escape keypress

      System.Windows.Forms.Keys kc =

        (System.Windows.Forms.Keys)(int)m.WParam &

        System.Windows.Forms.Keys.KeyCode;


      if (m.Msg == WM_KEYDOWN &&

          kc == System.Windows.Forms.Keys.Escape)

      {

        cancelled = true;

      }


      // Return true to filter all keypresses

      return true;

    }

    // Return false to let other messages through

    return false;

  }

}

In terms of how to use the class... first of all you create an instance of it, setting the string to be shown on the progress meter (just like AutoCAD's ProgressMeter class). As the LongOperationManager implements IDisposable, then at the end you should either call Dispose or manage it's scope with the using() statement.

I chose to separate the setting of the total number of operations to be completed from the object's construction, as in our example we need to an initial pass before we know how many objects we're working with (and we want to at least put the label on the progress meter while we perform that initial pass).

Then we just call the Tick() method whenever we perform an operation - this updates the progress meter and checks for use of the escape key. The idea is that you set the total number of operations and then call Tick() for each one of those individual operations - the class takes care of how often it needs to update the progress meter. If it finds escape has been used, the Tick() method will return false.

That's about it, aside from the fact you can also query the "cancelled" property to see whether escape has been used.

Here's the basic approach:

LongOperationManager lom =

  new LongOperationManager("Fractalizing entities");

 

using (lom)

{

  ...

  lom.SetTotalOperations(totalOps);

  ...

  while (true)

  {

    ...

    if (!lom.Tick())

    {

      ed.WriteMessage("/nFractalization cancelled./n");

      break;

    }

  }

}

Here's the code integrated into the previous example, with the significant lines in red:

    1 using Autodesk.AutoCAD.ApplicationServices;

    2 using Autodesk.AutoCAD.DatabaseServices;

    3 using Autodesk.AutoCAD.EditorInput;

    4 using Autodesk.AutoCAD.Runtime;

    5 using Autodesk.AutoCAD.Geometry;

    6 using System.Collections.Generic;

    7 using System;

    8

    9 namespace Kochizer

   10 {

   11   public class Commands

   12   {

   13     // We generate 4 new entities for every old entity

   14     // (unless a complex entity such as a polyline)

   15

   16     const int newEntsPerOldEnt = 4;

   17

   18     [CommandMethod("KA")]

   19     public void KochizeAll()

   20     {

   21       Document doc =

   22         Application.DocumentManager.MdiActiveDocument;

   23       Database db = doc.Database;

   24       Editor ed = doc.Editor;

   25

   26       // Acquire user input - whether to create the

   27       // new geometry to the left or the right...

   28

   29       PromptKeywordOptions pko =

   30         new PromptKeywordOptions(

   31           "/nCreate fractal to side (Left/<Right>): "

   32         );

   33       pko.Keywords.Add("Left");

   34       pko.Keywords.Add("Right");

   35

   36       PromptResult pr =

   37         ed.GetKeywords(pko);

   38       bool bLeft = false;

   39

   40       if (pr.Status != PromptStatus.None &&

   41           pr.Status != PromptStatus.OK)

   42         return;

   43

   44       if ((string)pr.StringResult == "Left")

   45         bLeft = true;

   46

   47       // ... and the recursion depth for the command.

   48

   49       PromptIntegerOptions pio =

   50         new PromptIntegerOptions(

   51           "/nEnter recursion level <1>: "

   52         );

   53       pio.AllowZero = false;

   54       pio.AllowNegative = false;

   55       pio.AllowNone = true;

   56

   57       PromptIntegerResult pir =

   58         ed.GetInteger(pio);

   59       int recursionLevel = 1;

   60

   61       if (pir.Status != PromptStatus.None &&

   62           pir.Status != PromptStatus.OK)

   63         return;

   64

   65       if (pir.Status == PromptStatus.OK)

   66         recursionLevel = pir.Value;

   67

   68       // Create and add our long operation handler

   69       LongOperationManager lom =

   70         new LongOperationManager("Fractalizing entities");

   71

   72       using (lom)

   73       {

   74         // Note: strictly speaking we're not recursing,

   75         // we're iterating, but the effect to the user

   76         // is the same.

   77

   78         Transaction tr =

   79           doc.TransactionManager.StartTransaction();

   80         using (tr)

   81         {

   82           BlockTable bt =

   83             (BlockTable)tr.GetObject(

   84               db.BlockTableId,

   85               OpenMode.ForRead

   86             );

   87           using (bt)

   88           {

   89             // No need to open the block table record

   90             // for write, as we're just reading data

   91             // for now

   92

   93             BlockTableRecord btr =

   94               (BlockTableRecord)tr.GetObject(

   95                 bt[BlockTableRecord.ModelSpace],

   96                 OpenMode.ForRead

   97               );

   98             using (btr)

   99             {

  100               // List of changed entities

  101               // (will contain complex entities, such as

  102               // polylines"

  103

  104               ObjectIdCollection modified =

  105                 new ObjectIdCollection();

  106

  107               // List of entities to erase

  108               // (will contain replaced entities)

  109

  110               ObjectIdCollection toErase =

  111                 new ObjectIdCollection();

  112

  113               // List of new entitites to add

  114               // (will be processed recursively or

  115               // assed to the open block table record)

  116

  117               List<Entity> newEntities =

  118                 new List<Entity>(

  119                   db.ApproxNumObjects * newEntsPerOldEnt

  120                 );

  121

  122               // Kochize each entity in the open block

  123               // table record

  124

  125               foreach (ObjectId objId in btr)

  126               {

  127                 Entity ent =

  128                   (Entity)tr.GetObject(

  129                     objId,

  130                     OpenMode.ForRead

  131                   );

  132                 Kochize(

  133                   ent,

  134                   modified,

  135                   toErase,

  136                   newEntities,

  137                   bLeft

  138                 );

  139               }

  140

  141               // The number of operations is...

  142               //  The number of complex entities multiplied

  143               //  by the recursion level (they each get

  144               //  "kochized" once per level,

  145               //  even if that's a long operation)

  146               // plus

  147               //  (4^0 + 4^1 + 4^2 + 4^3... + 4^n) multiplied

  148               //  by the number of db-resident ents

  149               // where n is the recursion level. Phew!

  150

  151               long totalOps =

  152                     modified.Count * recursionLevel +

  153                     operationCount(recursionLevel) *

  154                     toErase.Count;

  155               lom.SetTotalOperations(totalOps);

  156

  157               // If we need to loop,

  158               // work on the returned entities

  159

  160               while (--recursionLevel > 0)

  161               {

  162                 // Create an output array

  163

  164                 List<Entity> newerEntities =

  165                   new List<Entity>(

  166                     newEntities.Count * newEntsPerOldEnt

  167                   );

  168

  169                 // Kochize all the modified (complex) ents

  170

  171                 foreach (ObjectId objId in modified)

  172                 {

  173                   if (!lom.Tick())

  174                   {

  175                     ed.WriteMessage(

  176                       "/nFractalization cancelled./n"

  177                     );

  178                     break;

  179                   }

  180

  181                   Entity ent =

  182                     (Entity)tr.GetObject(

  183                       objId,

  184                       OpenMode.ForRead

  185                     );

  186                   Kochize(

  187                     ent,

  188                     modified,

  189                     toErase,

  190                     newerEntities,

  191                     bLeft

  192                   );

  193                 }

  194

  195                 // Kochize all the non-db resident entities

  196                 if (!lom.cancelled)

  197                 {

  198                   foreach (Entity ent in newEntities)

  199                   {

  200                     if (!lom.Tick())

  201                     {

  202                       ed.WriteMessage(

  203                         "/nFractalization cancelled./n"

  204                       );

  205                       break;

  206                     }

  207                     Kochize(

  208                       ent,

  209                       modified,

  210                       toErase,

  211                       newerEntities,

  212                       bLeft

  213                     );

  214                   }

  215                 }

  216

  217                 // We now longer need the intermediate ents

  218                 // previously output for the level above,

  219                 // we replace them with the latest output

  220

  221                 newEntities.Clear();

  222                 newEntities = newerEntities;

  223               }

  224

  225               lom.Tick();

  226

  227               if (!lom.cancelled)

  228               {

  229                 // Erase each replaced db-resident ent

  230

  231                 foreach (ObjectId objId in toErase)

  232                 {

  233                   Entity ent =

  234                     (Entity)tr.GetObject(

  235                       objId,

  236                       OpenMode.ForWrite

  237                     );

  238                   ent.Erase();

  239                 }

  240

  241                 // Add the new entities

  242

  243                 btr.UpgradeOpen();

  244                 foreach (Entity ent in newEntities)

  245                 {

  246                   btr.AppendEntity(ent);

  247                   tr.AddNewlyCreatedDBObject(ent, true);

  248                 }

  249               }

  250               tr.Commit();

  251             }

  252           }

  253         }

  254       }

  255     }

  256

  257     static long

  258     operationCount(int nRecurse)

  259     {

  260       if (1 >= nRecurse)

  261         return 1;

  262       return

  263         (long)Math.Pow(

  264           newEntsPerOldEnt,

  265           nRecurse - 1

  266         )

  267         + operationCount(nRecurse - 1);

  268     }

  269

  270     // Dispatch function to call through to various per-type

  271     // functions

  272

  273     private void Kochize(

  274       Entity ent,

  275       ObjectIdCollection modified,

  276       ObjectIdCollection toErase,

  277       List<Entity> toAdd,

  278       bool bLeft

  279     )

  280     {

  281       Line ln = ent as Line;

  282       if (ln != null)

  283       {

  284         Kochize(ln, modified, toErase, toAdd, bLeft);

  285         return;

  286       }

  287       Arc arc = ent as Arc;

  288       if (arc != null)

  289       {

  290         Kochize(arc, modified, toErase, toAdd, bLeft);

  291         return;

  292       }

  293       Polyline pl = ent as Polyline;

  294       if (pl != null)

  295       {

  296         Kochize(pl, modified, toErase, toAdd, bLeft);

  297         return;

  298       }

  299     }

  300

  301     // Create 4 new lines from a line passed in

  302

  303     private void Kochize(

  304       Line ln,

  305       ObjectIdCollection modified,

  306       ObjectIdCollection toErase,

  307       List<Entity> toAdd,

  308       bool bLeft

  309     )

  310     {

  311       // Get general info about the line

  312       // and calculate the main 5 points

  313

  314       Point3d pt1 = ln.StartPoint,

  315               pt5 = ln.EndPoint;

  316       Vector3d vec1 = pt5 - pt1,

  317               norm1 = vec1.GetNormal();

  318       double d_3 = vec1.Length / 3;

  319       Point3d pt2 = pt1 + (norm1 * d_3),

  320               pt4 = pt1 + (2 * norm1 * d_3);

  321       Vector3d vec2 = pt4 - pt2;

  322

  323       if (bLeft)

  324         vec2 =

  325           vec2.RotateBy(

  326             Math.PI / 3, new Vector3d(0, 0, 1)

  327           );

  328       else

  329         vec2 =

  330           vec2.RotateBy(

  331             5 * Math.PI / 3, new Vector3d(0, 0, 1)

  332           );

  333       Point3d pt3 = pt2 + vec2;

  334

  335       // Mark the original to be erased

  336

  337       if (ln.ObjectId != ObjectId.Null)

  338         toErase.Add(ln.ObjectId);

  339

  340       // Create the first line

  341

  342       Line ln1 = new Line(pt1, pt2);

  343       ln1.SetPropertiesFrom(ln);

  344       ln1.Thickness = ln.Thickness;

  345       toAdd.Add(ln1);

  346

  347       // Create the second line

  348

  349       Line ln2 = new Line(pt2, pt3);

  350       ln2.SetPropertiesFrom(ln);

  351       ln2.Thickness = ln.Thickness;

  352       toAdd.Add(ln2);

  353

  354       // Create the third line

  355

  356       Line ln3 = new Line(pt3, pt4);

  357       ln3.SetPropertiesFrom(ln);

  358       ln3.Thickness = ln.Thickness;

  359       toAdd.Add(ln3);

  360

  361       // Create the fourth line

  362

  363       Line ln4 = new Line(pt4, pt5);

  364       ln4.SetPropertiesFrom(ln);

  365       ln4.Thickness = ln.Thickness;

  366       toAdd.Add(ln4);

  367     }

  368

  369     // Create 4 new arcs from an arc passed in

  370

  371     private void Kochize(

  372       Arc arc,

  373       ObjectIdCollection modified,

  374       ObjectIdCollection toErase,

  375       List<Entity> toAdd,

  376       bool bLeft

  377     )

  378     {

  379       // Get general info about the arc

  380       // and calculate the main 5 points

  381

  382       Point3d pt1 = arc.StartPoint,

  383               pt5 = arc.EndPoint;

  384       double length = arc.GetDistAtPoint(pt5),

  385             angle = arc.StartAngle;

  386       Vector3d full = pt5 - pt1;

  387

  388       Point3d pt2 = arc.GetPointAtDist(length / 3),

  389               pt4 = arc.GetPointAtDist(2 * length / 3);

  390

  391       // Mark the original to be erased

  392

  393       if (arc.ObjectId != ObjectId.Null)

  394         toErase.Add(arc.ObjectId);

  395

  396       // Create the first arc

  397

  398       Point3d mid = arc.GetPointAtDist(length / 6);

  399       CircularArc3d tmpArc =

  400         new CircularArc3d(pt1, mid, pt2);

  401       Arc arc1 = circArc2Arc(tmpArc);

  402       arc1.SetPropertiesFrom(arc);

  403       arc1.Thickness = arc.Thickness;

  404       toAdd.Add(arc1);

  405

  406       // Create the second arc

  407

  408       mid = arc.GetPointAtDist(length / 2);

  409       tmpArc.Set(pt2, mid, pt4);

  410       if (bLeft)

  411         tmpArc.RotateBy(Math.PI / 3, tmpArc.Normal, pt2);

  412       else

  413         tmpArc.RotateBy(5 * Math.PI / 3, tmpArc.Normal, pt2);

  414       Arc arc2 = circArc2Arc(tmpArc);

  415       arc2.SetPropertiesFrom(arc);

  416       arc2.Thickness = arc.Thickness;

  417       toAdd.Add(arc2);

  418

  419       // Create the third arc

  420

  421       tmpArc.Set(pt2, mid, pt4);

  422       if (bLeft)

  423         tmpArc.RotateBy(5 * Math.PI / 3, tmpArc.Normal, pt4);

  424       else

  425         tmpArc.RotateBy(Math.PI / 3, tmpArc.Normal, pt4);

  426       Arc arc3 = circArc2Arc(tmpArc);

  427       arc3.SetPropertiesFrom(arc);

  428       arc3.Thickness = arc.Thickness;

  429       toAdd.Add(arc3);

  430

  431       // Create the fourth arc

  432

  433       mid = arc.GetPointAtDist(5 * length / 6);

  434       Arc arc4 =

  435         circArc2Arc(new CircularArc3d(pt4, mid, pt5));

  436       arc4.SetPropertiesFrom(arc);

  437       arc4.Thickness = arc.Thickness;

  438       toAdd.Add(arc4);

  439     }

  440

  441     Arc circArc2Arc(CircularArc3d circArc)

  442     {

  443       Point3d center = circArc.Center;

  444       Vector3d normal = circArc.Normal;

  445       Vector3d refVec = circArc.ReferenceVector;

  446       Plane plane = new Plane(center, normal);

  447       double ang = refVec.AngleOnPlane(plane);

  448       return new Arc(

  449         center,

  450         normal,

  451         circArc.Radius,

  452         circArc.StartAngle + ang,

  453         circArc.EndAngle + ang

  454       );

  455     }

  456

  457     private void Kochize(

  458       Polyline pl,

  459       ObjectIdCollection modified,

  460       ObjectIdCollection toErase,

  461       List<Entity> toAdd,

  462       bool bLeft

  463     )

  464     {

  465       pl.UpgradeOpen();

  466

  467       if (pl.ObjectId != ObjectId.Null &&

  468           !modified.Contains(pl.ObjectId))

  469       {

  470         modified.Add(pl.ObjectId);

  471       }

  472

  473       for(int vn = 0; vn < pl.NumberOfVertices; vn++)

  474       {

  475         SegmentType st = pl.GetSegmentType(vn);

  476         if (st != SegmentType.Line && st != SegmentType.Arc)

  477           continue;

  478

  479         double sw = pl.GetStartWidthAt(vn),

  480               ew = pl.GetEndWidthAt(vn);

  481

  482         if (st == SegmentType.Line)

  483         {

  484           if (vn + 1 == pl.NumberOfVertices)

  485             continue;

  486

  487           LineSegment2d ls = pl.GetLineSegment2dAt(vn);

  488           Point2d pt1 = ls.StartPoint,

  489                   pt5 = ls.EndPoint;

  490           Vector2d vec = pt5 - pt1;

  491           double d_3 = vec.Length / 3;

  492           Point2d pt2 = pt1 + (vec.GetNormal() * d_3),

  493                   pt4 = pt1 + (vec.GetNormal() * 2 * d_3);

  494           Vector2d vec2 = pt4 - pt2;

  495

  496           if (bLeft)

  497             vec2 = vec2.RotateBy(Math.PI / 3);

  498           else

  499             vec2 = vec2.RotateBy(5 * Math.PI / 3);

  500

  501           Point2d pt3 = pt2 + vec2;

  502

  503           pl.AddVertexAt(++vn, pt2, 0, sw, ew);

  504           pl.AddVertexAt(++vn, pt3, 0, sw, ew);

  505           pl.AddVertexAt(++vn, pt4, 0, sw, ew);

  506         }

  507         else if (st == SegmentType.Arc)

  508         {

  509           CircularArc3d ca = pl.GetArcSegmentAt(vn);

  510           double oldBulge = pl.GetBulgeAt(vn);

  511

  512           // Build a standard arc and use that for the calcs

  513

  514           Arc arc = circArc2Arc(ca);

  515

  516           // Get the main 5 points

  517

  518           Point3d pt1 = arc.StartPoint,

  519                   pt5 = arc.EndPoint;

  520

  521           double ln = arc.GetDistAtPoint(pt5);

  522           Point3d pt2 = arc.GetPointAtDist(ln / 3),

  523                   pt4 = arc.GetPointAtDist(2 * ln / 3);

  524

  525           Point3d mid = arc.GetPointAtDist(ln / 2);

  526

  527           CircularArc3d tmpArc =

  528             new CircularArc3d(pt2, mid, pt4);

  529           if (bLeft)

  530             tmpArc.RotateBy(5*Math.PI/3, tmpArc.Normal, pt4);

  531           else

  532             tmpArc.RotateBy(Math.PI / 3, tmpArc.Normal, pt4);

  533

  534           Point3d pt3 = tmpArc.StartPoint;

  535

  536           // Now add the new segments, setting the bulge

  537           // for the existing one and the new ones to a third

  538           // (as the segs are a third as big as the old one)

  539

  540           CoordinateSystem3d ecs = pl.Ecs.CoordinateSystem3d;

  541           Plane pn = new Plane(ecs.Origin, pl.Normal);

  542           double bu = oldBulge / 3;

  543

  544           pl.SetBulgeAt(vn, bu);

  545           pl.AddVertexAt(

  546             ++vn, pt2.Convert2d(pn), bu, sw, ew);

  547           pl.AddVertexAt(

  548             ++vn, pt3.Convert2d(pn), bu, sw, ew);

  549           pl.AddVertexAt(

  550             ++vn, pt4.Convert2d(pn), bu, sw, ew);

  551         }

  552       }

  553       pl.DowngradeOpen();

  554     }

  555

  556     public class LongOperationManager :

  557       IDisposable, System.Windows.Forms.IMessageFilter

  558     {

  559       // The message code corresponding to a keypress

  560       const int WM_KEYDOWN = 0x0100;

  561

  562       // The number of times to update the progress meter

  563       // (for some reason you need 600 to tick through

  564       //  for each percent)

  565       const int progressMeterIncrements = 600;

  566

  567       // Internal members for metering progress

  568       private ProgressMeter pm;

  569       private long updateIncrement;

  570       private long currentInc;

  571

  572       // External flag for checking cancelled status

  573       public bool cancelled = false;

  574

  575       // Constructor

  576

  577       public LongOperationManager(string message)

  578       {

  579         System.Windows.Forms.Application.

  580           AddMessageFilter(this);

  581         pm = new ProgressMeter();

  582         pm.Start(message);

  583         pm.SetLimit(progressMeterIncrements);

  584         currentInc = 0;

  585       }

  586

  587       // System.IDisposable.Dispose

  588

  589       public void Dispose()

  590       {

  591         pm.Stop();

  592         pm.Dispose();

  593         System.Windows.Forms.Application.

  594           RemoveMessageFilter(this);

  595       }

  596

  597       // Set the total number of operations

  598

  599       public void SetTotalOperations(long totalOps)

  600       {

  601         // We really just care about when we need

  602         // to update the timer

  603         updateIncrement =

  604           (totalOps > progressMeterIncrements ?

  605             totalOps / progressMeterIncrements :

  606             totalOps

  607           );

  608       }

  609

  610       // This function is called whenever an operation

  611       // is performed

  612

  613       public bool Tick()

  614       {

  615         if (++currentInc == updateIncrement)

  616         {

  617           pm.MeterProgress();

  618           currentInc = 0;

  619           System.Windows.Forms.Application.DoEvents();

  620         }

  621         // Check whether the filter has set the flag

  622         if (cancelled)

  623           pm.Stop();

  624

  625         return !cancelled;

  626       }

  627

  628       // The message filter callback

  629

  630       public bool PreFilterMessage(

  631         ref System.Windows.Forms.Message m

  632       )

  633       {

  634         if (m.Msg == WM_KEYDOWN)

  635         {

  636           // Check for the Escape keypress

  637           System.Windows.Forms.Keys kc =

  638             (System.Windows.Forms.Keys)(int)m.WParam &

  639             System.Windows.Forms.Keys.KeyCode;

  640

  641           if (m.Msg == WM_KEYDOWN &&

  642               kc == System.Windows.Forms.Keys.Escape)

  643           {

  644             cancelled = true;

  645           }

  646

  647           // Return true to filter all keypresses

  648           return true;

  649         }

  650         // Return false to let other messages through

  651         return false;

  652       }

  653     }

  654   }

  655 }

  656

Here's the source the source file for download.

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章