├── .gitattributes
├── .gitignore
├── Aligner.py
├── Caliper.py
├── Init.py
├── InitGui.py
├── Manipulator.png
├── ManipulatorCMD.py
├── Mover.py
├── README.md
├── Resources
├── FreeCAD addon manager available.svg
├── FreeCAD-addon-manager-available.svg
├── Made with Python.svg
├── Made-with-Python_.svg
├── icons
│ ├── AlternativeLCS.svg
│ ├── Annotation.svg
│ ├── Caliper-selected.svg
│ ├── Caliper.svg
│ ├── Center-Align.svg
│ ├── Centering.svg
│ ├── DatumLCS.svg
│ ├── DatumLine.svg
│ ├── DatumPlane.svg
│ ├── DatumPoint.svg
│ ├── DatumTools-icon.svg
│ ├── Freecad.svg
│ ├── Manipulator-cmd.svg
│ ├── Manipulator-icon.svg
│ ├── RefPlane.svg
│ ├── centering-w.svg
│ └── datasheet.svg
├── made-with-python.svg
└── ui
│ ├── align-tool-docked-v1.6.ui
│ ├── align-tool-docked-v1.8-highdpi.ui
│ ├── align-tool-docked-v1.9-highdpi.ui
│ ├── align-tool-docked-v1.9c-highdpi.ui
│ ├── align-tool-docked-v1.9e-highdpi.ui
│ ├── align-tool-docked-v2.1-highdpi.ui
│ ├── measure-tool-docked-v1.8.ui
│ ├── measure-tool-docked-v1.8d-highdpi.ui
│ ├── measure-tool-docked-v1.9e-highdpi.ui
│ ├── measure-tool-docked-v2.0-test-highdpi.ui
│ ├── mover-tool-docked-v1.9.ui
│ ├── mover-tool-docked-v1.9d.ui
│ └── uic2py.bat
├── changelog.txt
├── commits_num_.py
├── get-full-selection-hierarchy.py
├── help
└── Manipulator-cheat-sheet.pdf
├── mvr_locator.py
├── oDraft.py
└── package.xml
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
4 | # Custom for Visual Studio
5 | *.cs diff=csharp
6 |
7 | # Standard to msysgit
8 | *.doc diff=astextplain
9 | *.DOC diff=astextplain
10 | *.docx diff=astextplain
11 | *.DOCX diff=astextplain
12 | *.dot diff=astextplain
13 | *.DOT diff=astextplain
14 | *.pdf diff=astextplain
15 | *.PDF diff=astextplain
16 | *.rtf diff=astextplain
17 | *.RTF diff=astextplain
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Windows image file caches
2 | Thumbs.db
3 | ehthumbs.db
4 |
5 | # Folder config file
6 | Desktop.ini
7 |
8 | # Recycle Bin used on file shares
9 | $RECYCLE.BIN/
10 |
11 | # Windows Installer files, back
12 | *.cab
13 | *.msi
14 | *.msm
15 | *.msp
16 | *.bak
17 |
18 | # Windows shortcuts
19 | *.lnk
20 |
21 | # Python executable
22 | *.pyc
23 |
24 | # =========================
25 | # Operating System Files
26 | # =========================
27 |
28 | # OSX
29 | # =========================
30 |
31 | .DS_Store
32 | .AppleDouble
33 | .LSOverride
34 |
35 | # Thumbnails
36 | ._*
37 |
38 | # Files that might appear in the root of a volume
39 | .DocumentRevisions-V100
40 | .fseventsd
41 | .Spotlight-V100
42 | .TemporaryItems
43 | .Trashes
44 | .VolumeIcon.icns
45 |
46 | # Directories potentially created on remote AFP share
47 | .AppleDB
48 | .AppleDesktop
49 | Network Trash Folder
50 | Temporary Items
51 | .apdisk
52 |
--------------------------------------------------------------------------------
/Init.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #****************************************************************************
3 | #* *
4 | #* Kicad STEPUP (TM) (3D kicad board and models to STEP) for FreeCAD *
5 | #* 3D exporter for FreeCAD *
6 | #* Kicad STEPUP TOOLS (TM) (3D kicad board and models to STEP) for FreeCAD *
7 | #* Copyright (c) 2015 *
8 | #* Maurice easyw@katamail.com *
9 | #* *
10 | #* Kicad STEPUP (TM) is a TradeMark and cannot be freely usable *
11 | #* *
12 |
13 | #FreeCAD.addImportType("Kicad pcb board/mod File Type (*.kicad_pcb *.kicad_mod)","kicadStepUptools")
14 |
--------------------------------------------------------------------------------
/InitGui.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #***************************************************************************
3 | #* *
4 | #* Copyright (c) 2017 *
5 | #* Maurice easyw@katamail.com *
6 | #* *
7 | #* code partially based on: *
8 | #* *
9 | # evolution of Macro_CenterFace *
10 | # some part of Macro WorkFeature *
11 | # and assembly2 *
12 | # *
13 | # Move objs along obj face Normal or edge *
14 | # *
15 | # (C) Maurice easyw-fc 2016 *
16 | # This program is free software; you can redistribute it and/or modify *
17 | # it under the terms of the GNU Library General Public License (LGPL) *
18 | # as published by the Free Software Foundation; either version 2 of *
19 | # the License, or (at your option) any later version. *
20 | # for detail see the LICENCE text file. *
21 | #****************************************************************************
22 |
23 | MWB_wb_version='v 1.6.0'
24 | global myurlMWB
25 | myurlMWB='https://github.com/easyw/Manipulator'
26 | global mycommitsMWB
27 | mycommitsMWB=201 # v 1.6.0
28 | # NB add cmtnum=197 to commit message
29 |
30 | import FreeCAD, FreeCADGui, Part, os, sys
31 | import re, time
32 |
33 | if (sys.version_info > (3, 0)): #py3
34 | import urllib
35 | from urllib import request, error #URLError, HTTPError
36 | else: #py2
37 | import urllib2
38 | from urllib2 import Request, urlopen, URLError, HTTPError
39 |
40 | import mvr_locator
41 | from ManipulatorCMD import *
42 |
43 | ManipulatorWBpath = os.path.dirname(mvr_locator.__file__)
44 | ManipulatorWB_icons_path = os.path.join( ManipulatorWBpath, 'Resources', 'icons')
45 |
46 | global main_MWB_Icon
47 | main_MWB_Icon = os.path.join( ManipulatorWB_icons_path , 'Manipulator-icon.svg')
48 |
49 | from PySide import QtGui
50 | from threading import Timer
51 |
52 |
53 | #try:
54 | # from FreeCADGui import Workbench
55 | #except ImportError as e:
56 | # FreeCAD.Console.PrintWarning("error")
57 |
58 | class ManipulatorWB ( Workbench ):
59 | global main_MWB_Icon, MWB_wb_version
60 |
61 | "Manipulator WB object"
62 | Icon = main_MWB_Icon
63 | #Icon = ":Resources/icons/kicad-StepUp-tools-WB.svg"
64 | MenuText = "Manipulator"
65 | ToolTip = "Aligner & Mover Manipulator workbench"
66 |
67 | def GetClassName(self):
68 | return "Gui::PythonWorkbench"
69 |
70 | def Initialize(self):
71 | #import ManipulatorCMD
72 | submenu = ['Manipulator-cheat-sheet.pdf']
73 | dirs = self.ListDemos()
74 |
75 | #self.appendToolbar("ksu Tools", ["ksuTools"])
76 | self.appendToolbar("Manipulator Tools", ["AlignerTools","MoverTools","CaliperTools","Separator","Separator","ResetPositions"])
77 | self.appendToolbar("Datum Tools", ["DatumPoint","DatumLine","DatumPlane","DatumLCS","AltLCS","Plane","AnnoLbl"]) #"Point","Line",
78 |
79 | #self.appendMenu("ksu Tools", ["ksuTools","ksuToolsEdit"])
80 | self.appendMenu("Manipulator Tools", ["AlignerTools"])
81 | self.appendMenu("Manipulator Tools", ["MoverTools"])
82 | self.appendMenu("Manipulator Tools", ["CaliperTools"])
83 | self.appendMenu("Manipulator Tools", ["ResetPositions"])
84 | self.appendMenu(["Manipulator Tools", "Help"], submenu)
85 |
86 | Log ("Loading Manipulator Module... done\n")
87 |
88 | def Activated(self):
89 | # do something here if needed...
90 | Msg ("Manipulator WB Activated("+MWB_wb_version+")\n")
91 | from PySide import QtGui, QtCore
92 | import time
93 | import commits_num_
94 |
95 | pg = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Manipulator")
96 | tnow = int(time.time())
97 | oneday = 86400
98 | if pg.IsEmpty():
99 | pg.SetBool("checkUpdates",1)
100 | upd=True
101 | pg.SetInt("updateDaysInterval",1)
102 | pg.SetInt("lastCheck",tnow-2*oneday)
103 | interval=True
104 | FreeCAD.Console.PrintError('new \'check for updates\' feature added!!!\n')
105 | msg="""
106 | new \'check for updates\' feature added!!!
107 |
108 |
set \'checkUpdates\' to \'False\' to avoid this checking
109 |
in \"Tools\", \"Edit Parameters\",
\"Preferences\"->\"Mod\"->\"Manipulator\"
110 | """
111 | QtGui.QApplication.restoreOverrideCursor()
112 | reply = QtGui.QMessageBox.information(None,"Warning", msg)
113 | else:
114 | upd=pg.GetBool("checkUpdates")
115 | time_interval = pg.GetInt("updateDaysInterval")
116 | if time_interval <= 0:
117 | time_interval = 1
118 | pg.SetInt("updateDaysInterval",1)
119 | nowTimeCheck = int(time.time())
120 | lastTimeCheck = pg.GetInt("lastCheck")
121 | #print (nowTimeCheck - lastTimeCheck)/(oneday*time_interval)
122 | if time_interval <= 0 or ((nowTimeCheck - lastTimeCheck)/(oneday*time_interval) >= 1):
123 | interval = True
124 | pg.SetInt("lastCheck",tnow)
125 | else:
126 | interval = False
127 |
128 | ##
129 | if upd and interval:
130 | # check_updates(myurlMWB, mycommitsMWB)
131 | nbr_commits=commits_num_.commitCount('easyw','Manipulator')
132 | url=myurlMWB
133 | commit_nbr=mycommitsMWB
134 | if int(nbr_commits) == 0:
135 | FreeCAD.Console.PrintWarning('We failed to get the commit numbers from github.\n')
136 | else:
137 | FreeCAD.Console.PrintMessage(url+'-> commits:'+str(nbr_commits)+'\n')
138 | delta = int(nbr_commits) - commit_nbr
139 | if delta > 0:
140 | s = ""
141 | if delta >1:
142 | s="s"
143 | FreeCAD.Console.PrintError('PLEASE UPDATE "Manipulator" WB.\n')
144 | msg="""
145 | PLEASE UPDATE "Manipulator" WB.
146 |
through \"Tools\" \"Addon manager\" Menu
147 |
your release is """+str(delta)+""" commit"""+s+""" behind
148 |
Manipulator WB
149 |
150 |
set \'checkUpdates\' to \'False\' to avoid this checking
151 |
in \"Tools\", \"Edit Parameters\",
\"Preferences\"->\"Mod\"->\"Manipulator\"
152 | """
153 | def warn_update_msg():
154 | QtGui.QApplication.restoreOverrideCursor()
155 | reply = QtGui.QMessageBox.information(None,"Warning", msg)
156 | if FreeCAD.GuiUp:
157 | # avoiding issue in losing panel & toolbar settings
158 | from PySide import QtCore, QtGui
159 | dl=1000.0 #ms
160 | QtCore.QTimer.singleShot(dl,warn_update_msg)
161 | else:
162 | FreeCAD.Console.PrintMessage('the WB is Up to Date\n')
163 | #
164 |
165 | def Deactivated(self):
166 | # do something here if needed...
167 | Msg ("Manipulator WB Deactivated()\n")
168 |
169 | @staticmethod
170 | def ListDemos():
171 | import os
172 | import mvr_locator
173 |
174 | dirs = []
175 | # List all of the example files in an order that makes sense
176 | module_base_path = mvr_locator.module_path()
177 | help_dir_path = os.path.join(module_base_path, 'help')
178 | dirs = os.listdir(help_dir_path)
179 | dirs.sort()
180 |
181 | return dirs
182 |
183 | ###
184 |
185 | dirs = ManipulatorWB.ListDemos()
186 | #FreeCADGui.addCommand('ksuWBOpenDemo', ksuOpenDemo())
187 | #dirs = ksuWB.ListDemos()
188 | for curFile in dirs:
189 | FreeCADGui.addCommand(curFile, ManpHelpFiles(curFile))
190 |
191 | FreeCADGui.addWorkbench(ManipulatorWB)
192 |
--------------------------------------------------------------------------------
/Manipulator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/easyw/Manipulator/e60e1b800291aead18b2282769ab04b85cbe5f8e/Manipulator.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Manipulator-WB
2 | ==============
3 |
4 | [](https://www.python.org/)
5 |
6 | [](https://www.freecad.org)
7 |
8 | **FreeCAD Manipulator WorkBench**
9 |
10 | **Manipulator features:**
11 |
12 | - **Align
, Move and Rotate
and Measure
helpers for Part, App::Part and Body objects**
13 |
14 | - **Each Tool has a Help Button
to get some useful tips**
15 |
16 | 
17 |
18 | **Manipulator in action**
19 |
20 | **Aligner:**
21 |
22 |
23 |
24 | **Mover:**
25 |
26 |
27 |
28 | **Caliper:**
29 |
30 |
31 |
32 |
33 |
34 |
35 | Installing
36 | ----------
37 |
38 | Download and install your corresponding version of FreeCAD from [wiki Download page](http://www.freecadweb.org/wiki/Download) and either install
39 |
40 | - automatically using the [FreeCAD Add-on Manager](https://github.com/FreeCAD/FreeCAD-addons) (bundled in to 0.17 dev version under Tools Menu)
41 | - manually by copying the Manipulator folder to the Mod sub-directory of the FreeCAD application.
42 |
43 | Manipulator Cheat sheet
44 | ------------------
45 |
46 | [Manipulator Cheat sheet](help/Manipulator-cheat-sheet.pdf)
47 |
48 | ### Requirements
49 |
50 | - FreeCAD v0.15 4671
51 | - **FreeCAD v0.16 >= 6712**
52 | - **FreeCAD v0.17 >= 11707**
53 | - **FreeCAD v0.18+**
54 |
55 |
56 | ### License
57 |
58 | [GNU GENERAL PUBLIC LICENSE](https://www.gnu.org/licenses/gpl.html)
59 |
--------------------------------------------------------------------------------
/Resources/FreeCAD addon manager available.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/FreeCAD-addon-manager-available.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/Made with Python.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/Made-with-Python_.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/icons/AlternativeLCS.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
441 |
--------------------------------------------------------------------------------
/Resources/icons/Annotation.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
166 |
--------------------------------------------------------------------------------
/Resources/icons/Caliper-selected.svg:
--------------------------------------------------------------------------------
1 |
2 |
402 |
--------------------------------------------------------------------------------
/Resources/icons/Caliper.svg:
--------------------------------------------------------------------------------
1 |
2 |
372 |
--------------------------------------------------------------------------------
/Resources/icons/Center-Align.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
207 |
--------------------------------------------------------------------------------
/Resources/icons/Centering.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
182 |
--------------------------------------------------------------------------------
/Resources/icons/DatumLCS.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
130 |
--------------------------------------------------------------------------------
/Resources/icons/DatumLine.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
92 |
--------------------------------------------------------------------------------
/Resources/icons/DatumPlane.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
131 |
--------------------------------------------------------------------------------
/Resources/icons/DatumPoint.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
81 |
--------------------------------------------------------------------------------
/Resources/icons/DatumTools-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
89 |
--------------------------------------------------------------------------------
/Resources/icons/Freecad.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/Resources/icons/Manipulator-cmd.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
500 |
--------------------------------------------------------------------------------
/Resources/icons/Manipulator-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
388 |
--------------------------------------------------------------------------------
/Resources/icons/RefPlane.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
447 |
--------------------------------------------------------------------------------
/Resources/icons/centering-w.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
175 |
--------------------------------------------------------------------------------
/Resources/icons/datasheet.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
223 |
--------------------------------------------------------------------------------
/Resources/made-with-python.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Resources/ui/uic2py.bat:
--------------------------------------------------------------------------------
1 | :: https://stackoverflow.com/questions/27629864/pyqt5-pyuic5-module-pyqt5-uic-not-found
2 |
3 | c:\Python3\python -m PyQt5.uic.pyuic -x %1 -o %1.py
--------------------------------------------------------------------------------
/changelog.txt:
--------------------------------------------------------------------------------
1 | - v1.2.4 WB improved Moving and Aligning Parts (semi-circles axis)
2 | - v1.1.4 WB added checking updates
3 | - v1.3.8 Mover & Aligner Tools working with Part, App::Part and Body objects
4 | - v1.2.8 Caliper Tools: measuring Length, Radius, Distance for Part, App::Part and Body objects with Annotation Plane option
5 | - v1.3.1 initial support for LCS
6 | - v1.3.2 adding Datum tools
7 | - initial release
8 |
--------------------------------------------------------------------------------
/commits_num_.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | # from https://gist.github.com/codsane/25f0fd100b565b3fce03d4bbd7e7bf33
4 |
5 | def commitCount(u, r):
6 | # print('https://api.github.com/repos/{}/{}/commits?per_page=1'.format(u, r))
7 | try:
8 | #cuc
9 | import requests
10 | res = requests.get('https://api.github.com/repos/{}/{}/commits?per_page=1'.format(u, r))
11 | # return res
12 | if hasattr(res, 'links'):
13 | return re.search('\d+$', res.links['last']['url']).group()
14 | return '0'
15 | except:
16 | import urllib
17 | print('using urllib')
18 | from urllib import request, error #URLError, HTTPError
19 | req = request.Request('https://api.github.com/repos/{}/{}/commits?per_page=1'.format(u, r))
20 | try:
21 | response = request.urlopen(req)
22 | resp_ok = True
23 | the_page = response.read().decode("utf-8")
24 | i=(the_page.find("message"))
25 | j=the_page[i+10:].find("\"")
26 | cmt_msg=the_page[i+10:i+10+j]
27 | #print(cmt_msg)
28 | #cmt_msg+="_cmtnum=634" NB all the commits must have commit message ending with _cmtnum=nnn
29 | k=cmt_msg.find("cmtnum=")
30 | if k:
31 | return(cmt_msg[k+7:])
32 | else:
33 | return('0')
34 | # print (int(cmt_msg[k+8:]))
35 | # print(the_page.find("message"))
36 | # print(the_page[i+10:].find("\""))
37 | # print(the_page[i+10:24])
38 | # print(the_page[i+10:i+10+24])
39 |
40 | except error.HTTPError as e:
41 | FreeCAD.Console.PrintWarning('The server couldn\'t fulfill the request.')
42 | FreeCAD.Console.PrintWarning('Error code: ' + str(e.code)+'\n')
43 | return '0'
44 | except error.URLError as e:
45 | FreeCAD.Console.PrintWarning('We failed to reach a server.\n')
46 | FreeCAD.Console.PrintWarning('Reason: '+ str(e.reason)+'\n')
47 | return '0'
48 | #
49 | def latestCommitInfo(u, r):
50 | """ Get info about the latest commit of a GitHub repo """
51 | response = requests.get('https://api.github.com/repos/{}/{}/commits?per_page=1'.format(u, r))
52 | commit = response.json()[0]; commit['number'] = re.search('\d+$', response.links['last']['url']).group()
53 | return commit
54 |
55 |
56 |
57 | # u='easyw'
58 | # r='kicadStepUpMod'
59 | # print(int(commitCount(u, r)))
60 | # # print(latestCommitInfo(u, r))
61 | #
62 | # u='easyw'
63 | # r='Manipulator'
64 | # print(int(commitCount(u, r)))
--------------------------------------------------------------------------------
/get-full-selection-hierarchy.py:
--------------------------------------------------------------------------------
1 | #FreeCADGui.Selection.getSelectionEx('', 0)
2 |
3 | ## Use getSelectionEx('', 0) to obtained the full hierarchy information. The first argument is the document name,
4 | ## empty string means current document, '*' means all document.
5 | ## The second argument 1 means resolve sub-object, which is the default value. 0 means full hierarchy.
6 |
7 | Code: Select all
8 |
9 | for sel in FreeCADGui.Selection.getSelectionEx('', 0):
10 | print('sel object: ' + sel.Object.Name)
11 | for sub in sel.SubElementNames:
12 | print('sub: ' + sub)
13 |
14 |
--------------------------------------------------------------------------------
/help/Manipulator-cheat-sheet.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/easyw/Manipulator/e60e1b800291aead18b2282769ab04b85cbe5f8e/help/Manipulator-cheat-sheet.pdf
--------------------------------------------------------------------------------
/mvr_locator.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #****************************************************************************
3 | #* *
4 | #* Kicad STEPUP (TM) (3D kicad board and models to STEP) for FreeCAD *
5 | #* 3D exporter for FreeCAD *
6 | #* Kicad STEPUP TOOLS (TM) (3D kicad board and models to STEP) for FreeCAD *
7 | #* Copyright (c) 2015 *
8 | #* Maurice easyw@katamail.com *
9 | #* *
10 | #* Kicad STEPUP (TM) is a TradeMark and cannot be freely usable *
11 | #* *
12 |
13 |
14 | import os, sys
15 |
16 | def module_path():
17 | #return os.path.dirname(unicode(__file__, encoding))
18 | return os.path.dirname(__file__)
19 |
20 | def abs_module_path():
21 | #return os.path.dirname(unicode(__file__, encoding))
22 | #return os.path.dirname(__file__)
23 | return os.path.realpath(__file__)
24 |
--------------------------------------------------------------------------------
/package.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Manipulator Workbench
4 | A handy way to Move and Align objects in FreeCAD.
5 | 1.6.0
6 | Maui
7 | GPLv3.0
8 | https://github.com/easyw/Manipulator
9 | Resources/icons/Manipulator-icon.svg
10 |
11 |
12 |
13 | ManipulatorWB
14 | ./
15 | 0.18
16 | Move
17 | Align
18 | 3D
19 | step
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------