├── pages
├── __init__.py
├── page_landing.py
├── page_patterns.py
├── helpers.py
├── page_inverse.py
├── shared.py
└── page_kinematics.py
├── tests
├── __init__.py
├── test_misc.py
├── README.md
├── test_kinematics.py
├── test_patterns.py
├── helpers.py
├── ik_cases
│ ├── case1.py
│ ├── case2.py
│ └── case3.py
├── test_ik.py
├── pattern_cases
│ ├── case1.py
│ └── case2.py
└── kinematics_cases
│ ├── case1.py
│ └── case2.py
├── hexapod
├── __init__.py
├── templates
│ ├── pose_template.py
│ └── figure_template.py
├── ik_solver
│ ├── shared.py
│ ├── recompute_hexapod.py
│ ├── helpers.py
│ ├── README.md
│ ├── ik_solver.py
│ └── ik_solver2.py
├── const.py
├── ground_contact_solver
│ ├── shared.py
│ ├── ground_contact_solver2.py
│ └── ground_contact_solver.py
├── plotter.py
├── linkage.py
├── points.py
└── models.py
├── widgets
├── __init__.py
├── pose_control
│ ├── generic_input_ui.py
│ ├── generic_daq_slider_ui.py
│ ├── components.py
│ ├── generic_slider_ui.py
│ ├── kinematics_section_maker.py
│ └── joint_widget_maker.py
├── section_maker.py
├── leg_patterns_ui.py
├── dimensions_ui.py
└── ik_ui.py
├── Procfile
├── .gitignore
├── assets
└── favicon.ico
├── app.py
├── .flake8
├── checker
├── texts.py
├── requirements.txt
├── .travis.yml
├── .codeclimate.yml
├── LICENSE
├── settings.py
├── index.py
├── style_settings.py
├── README.md
├── external_css
├── dark.css
└── light.css
└── .pylintrc
/pages/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/hexapod/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/widgets/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: gunicorn index:server
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | **/__pycache__
3 | .vscode/settings.json
4 | venv
5 | .env
6 | *.pyc
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mithi/hexapod-robot-simulator/HEAD/assets/favicon.ico
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import dash
3 | from texts import APP_TITLE
4 | from style_settings import EXTERNAL_STYLESHEETS
5 |
6 | app = dash.Dash(__name__, external_stylesheets=EXTERNAL_STYLESHEETS)
7 | app.title = APP_TITLE
8 | server = app.server
9 | server.secret_key = os.environ.get("secret_key", "secret")
10 | app.config.suppress_callback_exceptions = True
11 |
--------------------------------------------------------------------------------
/pages/page_landing.py:
--------------------------------------------------------------------------------
1 | import dash_html_components as html
2 | from texts import URL_IMG_LANDING
3 |
4 | img = html.Img(src=URL_IMG_LANDING, style={"width": "100%", "height": "auto"},)
5 |
6 | layout = html.Div(
7 | [
8 | html.Div(img, style={"width": "20%", "height": "auto"}),
9 | html.Div(html.Br(), style={"width": "auto", "height": "auto"}),
10 | ],
11 | style={"display": "flex", "flex-direction": "row"},
12 | )
13 |
--------------------------------------------------------------------------------
/tests/test_misc.py:
--------------------------------------------------------------------------------
1 | from settings import (
2 | DEBUG_MODE,
3 | ASSERTION_ENABLED,
4 | PRINT_IK_LOCAL_LEG,
5 | PRINT_IK,
6 | PRINT_MODEL_ON_UPDATE,
7 | RECOMPUTE_HEXAPOD,
8 | )
9 |
10 |
11 | def test_deploy_minimum():
12 | assert not DEBUG_MODE
13 | assert not ASSERTION_ENABLED
14 | assert not PRINT_IK_LOCAL_LEG
15 | assert not PRINT_IK
16 | assert not PRINT_MODEL_ON_UPDATE
17 | assert RECOMPUTE_HEXAPOD
18 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = E203, E266, E501, W503
3 | # See https://github.com/psf/black/blob/master/README.md#line-length for more details
4 | max-line-length = 90
5 | max-complexity = 18
6 | select = B,C,E,F,W,T4,B9
7 | # We need to configure the mypy.ini because the flake8-mypy's default
8 | # options don't properly override it, so if we don't specify it we get
9 | # half of the config from mypy.ini and half from flake8-mypy.
10 | mypy_config = mypy.ini
--------------------------------------------------------------------------------
/checker:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | black .
3 | python -m pytest
4 | python -m py_compile ./*/*.py ./*.py ./*/*/*.py
5 | flake8 ./*/*.py ./*.py ./*/*/*.py
6 | pylint ./*.py */*.py */*/*.py --reports=y --ignore=page_kinematics.py,ik_solver.py --disable=broad-except,too-few-public-methods,attribute-defined-outside-init,too-many-locals,too-many-instance-attributes,too-many-arguments,bad-continuation,missing-class-docstring,missing-module-docstring,missing-function-docstring,invalid-name,duplicate-code
7 |
--------------------------------------------------------------------------------
/widgets/pose_control/generic_input_ui.py:
--------------------------------------------------------------------------------
1 | from widgets.pose_control.joint_widget_maker import (
2 | make_all_joint_widgets,
3 | make_number_widget,
4 | )
5 | from widgets.pose_control.kinematics_section_maker import make_section
6 |
7 | # ................................
8 | # COMPONENTS
9 | # ................................
10 |
11 | widgets = make_all_joint_widgets(joint_input_function=make_number_widget)
12 | KINEMATICS_WIDGETS_SECTION = make_section(widgets, add_joint_names=True)
13 |
--------------------------------------------------------------------------------
/widgets/pose_control/generic_daq_slider_ui.py:
--------------------------------------------------------------------------------
1 | from widgets.pose_control.joint_widget_maker import (
2 | make_all_joint_widgets,
3 | make_daq_slider,
4 | )
5 | from widgets.pose_control.kinematics_section_maker import make_section
6 |
7 | # ................................
8 | # COMPONENTS
9 | # ................................
10 |
11 | widgets = make_all_joint_widgets(joint_input_function=make_daq_slider)
12 | KINEMATICS_WIDGETS_SECTION = make_section(
13 | widgets, style_to_use={"padding": "0 0 0 3em"}
14 | )
15 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Inverse Kinematics Edge Cases
3 |
4 | - Coxia point shoved on ground
5 | - Body contact shoved on ground
6 | - Can't reach target ground point
7 | - Femur length is too long
8 | - Tibia length is too long
9 | - The ground is blocking the path
10 | - Legs too short
11 | - Too many legs off the floor
12 | - All left legs off the ground
13 | - All right legs off the ground
14 | - Angle required is beyond range of motion
15 | - Alpha
16 | - Beta
17 | - Gamma
18 |
19 | # `VirtualHexapod.Update` Edge Cases
20 |
21 | - Unstable. Center of gravity is outside the hexapod's support polygon
--------------------------------------------------------------------------------
/texts.py:
--------------------------------------------------------------------------------
1 | APP_TITLE = "Mithi's Hexapod Robot Simulator"
2 |
3 | URL_KOFI = "https://ko-fi.com/minimithi"
4 | URL_REPO = "https://github.com/mithi/hexapod-robot-simulator"
5 | URL_IMG_LANDING = "https://mithi.github.io/robotics-blog/v2-hexapod-1.gif"
6 |
7 | KINEMATICS_PAGE_PATH = "/kinematics"
8 | IK_PAGE_PATH = "/inverse-kinematics"
9 | PATTERNS_PAGE_PATH = "/leg-patterns"
10 | ROOT_PATH = "/"
11 |
12 | DIMENSIONS_WIDGETS_HEADER = "robot dimensions".upper()
13 | PATTERNS_WIDGETS_HEADER = "leg patterns".upper()
14 | IK_WIDGETS_HEADER = "inverse kinematics".upper()
15 | KINEMATICS_WIDGETS_HEADER = "kinematics".upper()
16 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | astroid==2.4.2
2 | Brotli==1.0.9
3 | click==7.1.2
4 | dash==1.18.1
5 | dash-core-components==1.14.1
6 | dash-daq==0.5.0
7 | dash-html-components==1.1.1
8 | dash-renderer==1.8.3
9 | dash-table==4.11.1
10 | flake8==3.8.4
11 | Flask==1.1.2
12 | Flask-Compress==1.8.0
13 | future==0.18.3
14 | gunicorn==20.0.4
15 | isort==5.7.0
16 | itsdangerous==1.1.0
17 | Jinja2==2.11.3
18 | lazy-object-proxy==1.4.3
19 | MarkupSafe==1.1.1
20 | mccabe==0.6.1
21 | numpy==1.22.0
22 | plotly==4.14.1
23 | pycodestyle==2.6.0
24 | pyflakes==2.2.0
25 | pylint==2.6.0
26 | retrying==1.3.3
27 | six==1.15.0
28 | toml==0.10.2
29 | Werkzeug==2.2.3
30 | wrapt==1.12.1
31 |
--------------------------------------------------------------------------------
/tests/test_kinematics.py:
--------------------------------------------------------------------------------
1 | from hexapod.models import VirtualHexapod
2 | from tests.kinematics_cases import case1, case2
3 | from tests.helpers import assert_hexapod_points_equal
4 |
5 | CASES = [case1, case2]
6 |
7 |
8 | def assert_kinematics(case, assume_ground_targets):
9 | hexapod = VirtualHexapod(case.given_dimensions)
10 | hexapod.update(case.given_poses, assume_ground_targets)
11 | assert_hexapod_points_equal(
12 | hexapod, case.correct_body_points, case.correct_leg_points, case.description
13 | )
14 |
15 |
16 | def test_sample_kinematics():
17 | for case in CASES:
18 | assert_kinematics(case, True)
19 | assert_kinematics(case, False)
20 |
--------------------------------------------------------------------------------
/hexapod/templates/pose_template.py:
--------------------------------------------------------------------------------
1 | # pose = {
2 | # LEG_ID: {
3 | # 'name': LEG_NAME,
4 | # 'id': LEG_ID
5 | # 'coxia': ALPHA,
6 | # 'femur': BETA,
7 | # 'tibia': GAMMA}
8 | # }
9 | # ...
10 | # }
11 |
12 | HEXAPOD_POSE = {
13 | 0: {"coxia": 0, "femur": 0, "tibia": 0, "name": "right-middle", "id": 0},
14 | 1: {"coxia": 0, "femur": 0, "tibia": 0, "name": "right-front", "id": 1},
15 | 2: {"coxia": 0, "femur": 0, "tibia": 0, "name": "left-front", "id": 2},
16 | 3: {"coxia": 0, "femur": 0, "tibia": 0, "name": "left-middle", "id": 3},
17 | 4: {"coxia": 0, "femur": 0, "tibia": 0, "name": "left-back", "id": 4},
18 | 5: {"coxia": 0, "femur": 0, "tibia": 0, "name": "right-back", "id": 5},
19 | }
20 |
--------------------------------------------------------------------------------
/tests/test_patterns.py:
--------------------------------------------------------------------------------
1 | from pages.helpers import make_pose
2 | from hexapod.models import VirtualHexapod
3 | from tests.pattern_cases import case1, case2
4 | from tests.helpers import assert_hexapod_points_equal
5 |
6 |
7 | def test_sample_patterns():
8 | for case in [case1, case2]:
9 | for assume_ground_targets in [True, False]:
10 | poses = make_pose(case.alpha, case.beta, case.gamma)
11 | hexapod = VirtualHexapod(case.given_dimensions)
12 | hexapod.update(poses, assume_ground_targets)
13 | assert_hexapod_points_equal(
14 | hexapod,
15 | case.correct_body_points,
16 | case.correct_leg_points,
17 | case.description,
18 | )
19 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # Disable sudo to speed up the build
2 | sudo: false
3 |
4 | language: python
5 | python:
6 | - "3.8.1"
7 | cache: pip
8 | install:
9 | - pip install -r requirements.txt
10 | - pip install codecov
11 | script:
12 | - python -m py_compile ./*/*.py ./*.py ./*/*/*.py
13 | - flake8 ./*/*.py ./*.py ./*/*/*.py
14 | - pylint ./*.py */*.py */*/*.py --reports=y --ignore=page_kinematics.py,ik_solver.py --disable=broad-except,too-few-public-methods,attribute-defined-outside-init,too-many-locals,too-many-instance-attributes,too-many-arguments,bad-continuation,missing-class-docstring,missing-module-docstring,missing-function-docstring,invalid-name,duplicate-code
15 | #- python -m pytest
16 | - coverage run -m pytest
17 | - coverage report -m
18 |
19 | # Push the results back to codecov
20 | after_success:
21 | - codecov
22 |
--------------------------------------------------------------------------------
/hexapod/ik_solver/shared.py:
--------------------------------------------------------------------------------
1 | # Functions used for solving inverse kinematics
2 | from hexapod.points import is_counter_clockwise, angle_between, rotz
3 |
4 |
5 | def update_hexapod_points(hexapod, leg_id, points):
6 | leg = hexapod.legs[leg_id]
7 | for point, leg_point in zip(points, leg.all_points):
8 | point.name = leg_point.name
9 | leg.all_points = points
10 |
11 |
12 | def find_twist_frame(hexapod, unit_coxia_vector):
13 | twist = angle_between(unit_coxia_vector, hexapod.x_axis)
14 | is_ccw = is_counter_clockwise(unit_coxia_vector, hexapod.x_axis, hexapod.z_axis)
15 | if is_ccw:
16 | twist = -twist
17 |
18 | twist_frame = rotz(twist)
19 | return twist, twist_frame
20 |
21 |
22 | def compute_twist_wrt_to_world(alpha, coxia_axis):
23 | alpha = (alpha - coxia_axis) % 360
24 | if alpha > 180:
25 | return alpha - 360
26 | return alpha
27 |
--------------------------------------------------------------------------------
/widgets/pose_control/components.py:
--------------------------------------------------------------------------------
1 | import dash_core_components as dcc
2 | import dash_html_components as html
3 | from dash.dependencies import Input
4 | from texts import KINEMATICS_WIDGETS_HEADER
5 | from hexapod.const import NAMES_LEG, NAMES_JOINT
6 |
7 |
8 | def make_joint_callback_inputs_of_one_leg(leg_name):
9 | return [
10 | Input(f"widget-{leg_name}-{joint_name}", "value") for joint_name in NAMES_JOINT
11 | ]
12 |
13 |
14 | def make_all_joint_callback_inputs():
15 | callback_inputs = []
16 | for leg_name in NAMES_LEG:
17 | callback_inputs += make_joint_callback_inputs_of_one_leg(leg_name)
18 | return callback_inputs
19 |
20 |
21 | # ................................
22 | # COMPONENTS
23 | # ................................
24 |
25 | HEADER = html.Label(dcc.Markdown(f"**{KINEMATICS_WIDGETS_HEADER}**"))
26 | KINEMATICS_CALLBACK_INPUTS = make_all_joint_callback_inputs()
27 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | version: "2" # required to adjust maintainability checks
2 | exclude_patterns:
3 | - "hexapod/plotter.py"
4 | - "hexapod/templates/figure_template.py"
5 | - "tests/*_cases/*.py"
6 |
7 | checks:
8 | argument-count:
9 | config:
10 | threshold: 6 # 4
11 | complex-logic:
12 | config:
13 | threshold: 4
14 | file-lines:
15 | config:
16 | threshold: 250
17 | method-complexity:
18 | config:
19 | threshold: 6
20 | method-count:
21 | config:
22 | threshold: 20
23 | method-lines:
24 | config:
25 | threshold: 25
26 | nested-control-flow:
27 | config:
28 | threshold: 4
29 | return-statements:
30 | config:
31 | threshold: 4
32 | similar-code:
33 | config:
34 | threshold: # language-specific defaults. an override will affect all languages.
35 | identical-code:
36 | config:
37 | threshold: # language-specific defaults. an override will affect all languages.
--------------------------------------------------------------------------------
/hexapod/const.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | from hexapod.plotter import HexapodPlotter
3 | from hexapod.models import VirtualHexapod, Hexagon, Linkage
4 | from hexapod.templates.figure_template import HEXAPOD_FIGURE
5 | from hexapod.templates.pose_template import HEXAPOD_POSE
6 |
7 | NAMES_LEG = Hexagon.VERTEX_NAMES
8 | NAMES_JOINT = Linkage.POINT_NAMES
9 |
10 | BASE_DIMENSIONS = {
11 | "front": 100,
12 | "side": 100,
13 | "middle": 100,
14 | "coxia": 100,
15 | "femur": 100,
16 | "tibia": 100,
17 | }
18 |
19 |
20 | BASE_IK_PARAMS = {
21 | "hip_stance": 0,
22 | "leg_stance": 0,
23 | "percent_x": 0,
24 | "percent_y": 0,
25 | "percent_z": 0,
26 | "rot_x": 0,
27 | "rot_y": 0,
28 | "rot_z": 0,
29 | }
30 |
31 | BASE_POSE = deepcopy(HEXAPOD_POSE)
32 |
33 | BASE_HEXAPOD = VirtualHexapod(BASE_DIMENSIONS)
34 | BASE_PLOTTER = HexapodPlotter()
35 |
36 | HEXAPOD = deepcopy(BASE_HEXAPOD)
37 | HEXAPOD.update(HEXAPOD_POSE)
38 | BASE_FIGURE = deepcopy(HEXAPOD_FIGURE)
39 | BASE_PLOTTER.update(BASE_FIGURE, HEXAPOD)
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Mithi Sevilla
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/widgets/pose_control/generic_slider_ui.py:
--------------------------------------------------------------------------------
1 | import dash_core_components as dcc
2 | import dash_html_components as html
3 | from hexapod.const import NAMES_LEG
4 | from widgets.section_maker import make_section_type4
5 | from widgets.pose_control.joint_widget_maker import (
6 | make_all_joint_widgets,
7 | make_slider,
8 | )
9 | from widgets.pose_control.components import HEADER
10 |
11 |
12 | def make_leg_sections(jwidgets):
13 | widget_sections = []
14 | header_section = make_section_type4(
15 | "", html.H5("coxia"), html.H5("femur"), html.H5("tibia")
16 | )
17 | widget_sections.append(header_section)
18 |
19 | for leg in NAMES_LEG:
20 | header = html.Label(dcc.Markdown("**`{}`**".format(leg)))
21 | coxia = jwidgets[leg]["coxia"]
22 | femur = jwidgets[leg]["femur"]
23 | tibia = jwidgets[leg]["tibia"]
24 | section = make_section_type4(header, coxia, femur, tibia)
25 | widget_sections.append(section)
26 |
27 | return html.Div(widget_sections)
28 |
29 |
30 | # ................................
31 | # COMPONENTS
32 | # ................................
33 |
34 | widgets = make_all_joint_widgets(joint_input_function=make_slider)
35 | sections = make_leg_sections(widgets)
36 | KINEMATICS_WIDGETS_SECTION = html.Div([HEADER, sections])
37 |
--------------------------------------------------------------------------------
/widgets/pose_control/kinematics_section_maker.py:
--------------------------------------------------------------------------------
1 | import dash_core_components as dcc
2 | import dash_html_components as html
3 | from widgets.section_maker import make_section_type3, make_section_type2
4 | from widgets.pose_control.components import HEADER
5 |
6 |
7 | def make_section(joint_widgets, add_joint_names=False, style_to_use=None):
8 | names = [
9 | "left-front",
10 | "right-front",
11 | "left-middle",
12 | "right-middle",
13 | "left-back",
14 | "right-back",
15 | ]
16 |
17 | lf, rf, lm, rm, lb, rb = [
18 | make_leg_section(name, joint_widgets, add_joint_names) for name in names
19 | ]
20 |
21 | widget_sections = html.Div(
22 | [
23 | make_section_type2(lf, rf),
24 | make_section_type2(lm, rm),
25 | make_section_type2(lb, rb),
26 | ],
27 | style=style_to_use or {},
28 | )
29 |
30 | return html.Div([HEADER, widget_sections])
31 |
32 |
33 | def code(name):
34 | return dcc.Markdown(f"`{name}`")
35 |
36 |
37 | def make_leg_section(name, joint_widgets, add_joint_names=False):
38 | header = html.Label(dcc.Markdown(f"( `{name.upper()}` )"))
39 | coxia = joint_widgets[name]["coxia"]
40 | femur = joint_widgets[name]["femur"]
41 | tibia = joint_widgets[name]["tibia"]
42 |
43 | if add_joint_names:
44 | section = make_section_type3(
45 | coxia, femur, tibia, code("coxia"), code("femur"), code("tibia")
46 | )
47 | else:
48 | section = make_section_type3(coxia, femur, tibia)
49 |
50 | return html.Div([header, section])
51 |
--------------------------------------------------------------------------------
/widgets/section_maker.py:
--------------------------------------------------------------------------------
1 | # Used to make html divisions
2 | import dash_html_components as html
3 |
4 |
5 | def make_section_type3(div1, div2, div3, name1="", name2="", name3=""):
6 | return html.Div(
7 | [
8 | html.Div([div1, name1], style={"width": "33%"}),
9 | html.Div([div2, name2], style={"width": "33%"}),
10 | html.Div([div3, name3], style={"width": "33%"}),
11 | ],
12 | style={"display": "flex"},
13 | )
14 |
15 |
16 | def make_section_type4(div1, div2, div3, div4):
17 | return html.Div(
18 | [
19 | html.Div(div1, style={"width": "16%"}),
20 | html.Div(div2, style={"width": "28%"}),
21 | html.Div(div3, style={"width": "28%"}),
22 | html.Div(div4, style={"width": "28%"}),
23 | ],
24 | style={"display": "flex"},
25 | )
26 |
27 |
28 | def make_section_type2(div1, div2):
29 | return html.Div(
30 | [
31 | html.Div(div1, style={"width": "50%"}),
32 | html.Div(div2, style={"width": "50%"}),
33 | ],
34 | style={"display": "flex"},
35 | )
36 |
37 |
38 | def make_section_type6(div1, div2, div3, div4, div5, div6):
39 | return html.Div(
40 | [
41 | html.Div(div1, style={"width": "17%"}),
42 | html.Div(div2, style={"width": "17%"}),
43 | html.Div(div3, style={"width": "17%"}),
44 | html.Div(div4, style={"width": "17%"}),
45 | html.Div(div5, style={"width": "16%"}),
46 | html.Div(div6, style={"width": "16%"}),
47 | ],
48 | style={"display": "flex"},
49 | )
50 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 |
3 |
4 | def assert_poses_equal(result_poses, correct_poses, description):
5 | for k, v in result_poses.items():
6 | msg = f"Unequal Poses\n{correct_poses[k]}\n{v}\n(case: {description})"
7 | assert correct_poses[k]["name"] == v["name"]
8 | assert correct_poses[k]["id"] == v["id"]
9 | assert np.isclose(correct_poses[k]["coxia"], v["coxia"]), msg
10 | assert np.isclose(correct_poses[k]["femur"], v["femur"]), msg
11 | assert np.isclose(correct_poses[k]["tibia"], v["tibia"]), msg
12 |
13 |
14 | def assert_hexapod_points_equal(
15 | hexapod, correct_body_points, correct_leg_points, description
16 | ):
17 | def msg(a, b):
18 | return f"Unequal Vectors\nexpected: {a}\n....found: {b}\n(case: {description})"
19 |
20 | for point_a, point_b in zip(correct_body_points, hexapod.body.all_points):
21 | assert point_a.__eq__(point_b, percent_tol=0.0075), msg(point_a, point_b)
22 |
23 | for leg_set, leg in zip(correct_leg_points, hexapod.legs):
24 | for point_a, point_b in zip(leg_set, leg.all_points):
25 | assert point_a.__eq__(point_b, percent_tol=0.0075), msg(point_a, point_b)
26 |
27 |
28 | def assert_two_hexapods_equal(hexapod1, hexapod2, description):
29 | def msg(a, b):
30 | return f"Unequal Vectors\n1: {a}\n2:{b}\n(case: {description})"
31 |
32 | for point_a, point_b in zip(hexapod1.body.all_points, hexapod2.body.all_points):
33 | assert point_a.__eq__(point_b, percent_tol=0.0075), msg(point_a, point_b)
34 |
35 | for leg_a, leg_b in zip(hexapod1.legs, hexapod2.legs):
36 | for point_a, point_b in zip(leg_a.all_points, leg_b.all_points):
37 | assert point_a.__eq__(point_b, percent_tol=0.0075), msg(point_a, point_b)
38 |
--------------------------------------------------------------------------------
/settings.py:
--------------------------------------------------------------------------------
1 | # ***************************
2 | # Settings
3 | # ***************************
4 |
5 | # The range of each leg joint in degrees
6 | ALPHA_MAX_ANGLE = 90
7 | BETA_MAX_ANGLE = 180
8 | GAMMA_MAX_ANGLE = 180
9 | BODY_MAX_ANGLE = 40
10 |
11 | # LEG STANCE
12 | # would define the starting leg position used to compute
13 | # the target ground contact for inverse kinematics poses
14 | # femur/ beta = -leg_stance
15 | # tibia/ gamma = leg_stance
16 | LEG_STANCE_MAX_ANGLE = 90
17 |
18 | # HIP STANCE
19 | # would defined the starting hip position used to compute
20 | # the target ground contact for inverse kinematics poses
21 | # coxia/alpha angle of
22 | # right_front = -hip_stance
23 | # left_front = +hip_stance
24 | # left_back = -hip_stance
25 | # right_back = +hip_stance
26 | # left_middle = 0
27 | # right_middle = 0
28 | HIP_STANCE_MAX_ANGLE = 45
29 |
30 | # Too slow? set UPDATE_MODE='mouseup'
31 | # Makes widgets only start updating when you release the mouse button
32 | UPDATE_MODE = "drag"
33 |
34 | DEBUG_MODE = False
35 | ASSERTION_ENABLED = False
36 |
37 | # The inverse kinematics solver already updates the points of the hexapod
38 | # But there is no guarantee that this pose is correct
39 | # So better update a fresh hexapod with the resulting poses
40 | RECOMPUTE_HEXAPOD = True
41 |
42 | PRINT_IK_LOCAL_LEG = False
43 | PRINT_IK = False
44 | PRINT_MODEL_ON_UPDATE = False
45 |
46 | # 1 - Use the daq slider UI
47 | # 2 - Use the generic slider UI
48 | # Anything else defaults to the generic input UI, which I prefer
49 | WHICH_POSE_CONTROL_UI = 0
50 |
51 | # Make it more granular to prevent overloading the server
52 | SLIDER_ANGLE_RESOLUTION = 1.5
53 | INPUT_DIMENSIONS_RESOLUTION = 1
54 |
55 | UI_GRAPH_HEIGHT = "600px"
56 | UI_GRAPH_WIDTH = "63%"
57 | UI_SIDEBAR_WIDTH = "37%"
58 |
--------------------------------------------------------------------------------
/widgets/leg_patterns_ui.py:
--------------------------------------------------------------------------------
1 | # Widgets used to set the leg pose of all legs uniformly
2 | import dash_core_components as dcc
3 | import dash_html_components as html
4 | from dash.dependencies import Input
5 | import dash_daq
6 | from texts import PATTERNS_WIDGETS_HEADER
7 | from settings import (
8 | ALPHA_MAX_ANGLE,
9 | BETA_MAX_ANGLE,
10 | GAMMA_MAX_ANGLE,
11 | UPDATE_MODE,
12 | SLIDER_ANGLE_RESOLUTION,
13 | )
14 | from style_settings import SLIDER_THEME, SLIDER_HANDLE_COLOR, SLIDER_COLOR
15 |
16 |
17 | def make_slider(slider_id, name, max_angle):
18 |
19 | handle_style = {
20 | "showCurrentValue": True,
21 | "color": SLIDER_HANDLE_COLOR,
22 | "label": name,
23 | }
24 |
25 | daq_slider = dash_daq.Slider( # pylint: disable=not-callable
26 | id=slider_id,
27 | min=-max_angle,
28 | max=max_angle,
29 | value=1.5,
30 | step=SLIDER_ANGLE_RESOLUTION,
31 | size=300,
32 | updatemode=UPDATE_MODE,
33 | handleLabel=handle_style,
34 | color={"default": SLIDER_COLOR},
35 | theme=SLIDER_THEME,
36 | )
37 |
38 | return html.Div(daq_slider, style={"padding": "2em"})
39 |
40 |
41 | # ................................
42 | # COMPONENTS
43 | # ................................
44 |
45 | HEADER = html.Label(dcc.Markdown(f"**{PATTERNS_WIDGETS_HEADER}**"))
46 | WIDGET_NAMES = ["alpha", "beta", "gamma"]
47 | PATTERNS_WIDGET_IDS = [f"widget-{name}" for name in WIDGET_NAMES]
48 | PATTERNS_CALLBACK_INPUTS = [Input(i, "value") for i in PATTERNS_WIDGET_IDS]
49 |
50 | max_angles = [ALPHA_MAX_ANGLE, BETA_MAX_ANGLE, GAMMA_MAX_ANGLE]
51 | widgets = [
52 | make_slider(id, name, angle)
53 | for id, name, angle in zip(PATTERNS_WIDGET_IDS, WIDGET_NAMES, max_angles)
54 | ]
55 | PATTERNS_WIDGETS_SECTION = html.Div([HEADER] + widgets)
56 |
--------------------------------------------------------------------------------
/widgets/dimensions_ui.py:
--------------------------------------------------------------------------------
1 | # Widgets used to set the dimensions of the hexapod
2 | import dash_core_components as dcc
3 | import dash_html_components as html
4 | from dash.dependencies import Input
5 | from texts import DIMENSIONS_WIDGETS_HEADER
6 | from settings import INPUT_DIMENSIONS_RESOLUTION
7 | from style_settings import NUMBER_INPUT_STYLE
8 | from widgets.section_maker import make_section_type3
9 |
10 |
11 | def make_number_widget(_name, _value):
12 | return dcc.Input(
13 | id=_name,
14 | type="number",
15 | value=_value,
16 | min=0,
17 | step=INPUT_DIMENSIONS_RESOLUTION,
18 | style=NUMBER_INPUT_STYLE,
19 | )
20 |
21 |
22 | def _code(name):
23 | return dcc.Markdown(f"`{name}`")
24 |
25 |
26 | # ................................
27 | # COMPONENTS
28 | # ................................
29 |
30 | HEADER = html.Label(dcc.Markdown(f"**{DIMENSIONS_WIDGETS_HEADER}**"))
31 | WIDGET_NAMES = ["front", "side", "middle", "coxia", "femur", "tibia"]
32 | DIMENSION_WIDGET_IDS = [f"widget-dimension-{name}" for name in WIDGET_NAMES]
33 | DIMENSION_CALLBACK_INPUTS = [Input(id, "value") for id in DIMENSION_WIDGET_IDS]
34 |
35 | widgets = [make_number_widget(widget_id, 100) for widget_id in DIMENSION_WIDGET_IDS]
36 | sections = [
37 | make_section_type3(
38 | widgets[0],
39 | widgets[1],
40 | widgets[2],
41 | _code(WIDGET_NAMES[0]),
42 | _code(WIDGET_NAMES[1]),
43 | _code(WIDGET_NAMES[2]),
44 | ),
45 | make_section_type3(
46 | widgets[3],
47 | widgets[4],
48 | widgets[5],
49 | _code(WIDGET_NAMES[3]),
50 | _code(WIDGET_NAMES[4]),
51 | _code(WIDGET_NAMES[5]),
52 | ),
53 | ]
54 |
55 | DIMENSIONS_WIDGETS_SECTION = html.Div(
56 | [HEADER, html.Div(sections, style={"display": "flex"}), html.Br()]
57 | )
58 |
--------------------------------------------------------------------------------
/pages/page_patterns.py:
--------------------------------------------------------------------------------
1 | import json
2 | from dash.dependencies import Output
3 | from app import app
4 | from hexapod.models import VirtualHexapod
5 | from hexapod.const import BASE_PLOTTER
6 | from widgets.leg_patterns_ui import PATTERNS_WIDGETS_SECTION, PATTERNS_CALLBACK_INPUTS
7 | from pages import helpers, shared
8 |
9 |
10 | # ......................
11 | # Page layout
12 | # ......................
13 |
14 | GRAPH_ID = "graph-patterns"
15 | MESSAGE_SECTION_ID = "message-patterns"
16 | PARAMETERS_SECTION_ID = "parameters-pattens"
17 |
18 | sidebar = shared.make_standard_page_sidebar(
19 | MESSAGE_SECTION_ID, PARAMETERS_SECTION_ID, PATTERNS_WIDGETS_SECTION
20 | )
21 |
22 | layout = shared.make_standard_page_layout(GRAPH_ID, sidebar)
23 |
24 |
25 | # ......................
26 | # Update page
27 | # ......................
28 |
29 | outputs, inputs, states = shared.make_standard_page_callback_params(
30 | GRAPH_ID, PARAMETERS_SECTION_ID, MESSAGE_SECTION_ID
31 | )
32 |
33 |
34 | @app.callback(outputs, inputs, states)
35 | def update_patterns_page(dimensions_json, poses_json, relayout_data, figure):
36 |
37 | dimensions = helpers.load_params(dimensions_json, "dims")
38 | poses = helpers.load_params(poses_json, "pose")
39 | hexapod = VirtualHexapod(dimensions)
40 |
41 | try:
42 | hexapod.update(poses)
43 | except Exception as alert:
44 | return figure, helpers.make_alert_message(alert)
45 |
46 | BASE_PLOTTER.update(figure, hexapod)
47 | helpers.change_camera_view(figure, relayout_data)
48 | return figure, ""
49 |
50 |
51 | # ......................
52 | # Update parameters
53 | # ......................
54 |
55 | output_parameter = Output(PARAMETERS_SECTION_ID, "children")
56 | input_parameters = PATTERNS_CALLBACK_INPUTS
57 |
58 |
59 | @app.callback(output_parameter, input_parameters)
60 | def update_poses_alpha_beta_gamma(alpha, beta, gamma):
61 | return json.dumps(helpers.make_pose(alpha, beta, gamma))
62 |
--------------------------------------------------------------------------------
/tests/ik_cases/case1.py:
--------------------------------------------------------------------------------
1 | description = "IK Random Pose #1"
2 |
3 | # ********************************
4 | # Dimensions
5 | # ********************************
6 |
7 | given_dimensions = {
8 | "front": 70,
9 | "side": 115,
10 | "middle": 120,
11 | "coxia": 60,
12 | "femur": 130,
13 | "tibia": 150,
14 | }
15 |
16 | # ********************************
17 | # IK Parameters
18 | # ********************************
19 |
20 | given_ik_parameters = {
21 | "hip_stance": 7,
22 | "leg_stance": 32,
23 | "percent_x": 0.35,
24 | "percent_y": 0.25,
25 | "percent_z": -0.2,
26 | "rot_x": 2.5,
27 | "rot_y": -9,
28 | "rot_z": 14,
29 | }
30 |
31 | # ********************************
32 | # Poses
33 | # ********************************
34 |
35 | correct_poses = {
36 | 0: {
37 | "name": "right-middle",
38 | "id": 0,
39 | "coxia": -36.89755490432384,
40 | "femur": 26.276957259313683,
41 | "tibia": -38.39772518650969,
42 | },
43 | 1: {
44 | "name": "right-front",
45 | "id": 1,
46 | "coxia": -31.715493484789533,
47 | "femur": 27.717090725335396,
48 | "tibia": -41.67638594657396,
49 | },
50 | 2: {
51 | "name": "left-front",
52 | "id": 2,
53 | "coxia": -3.1127758531426934,
54 | "femur": 64.38109364320302,
55 | "tibia": -41.751719577946915,
56 | },
57 | 3: {
58 | "name": "left-middle",
59 | "id": 3,
60 | "coxia": -14.447799823858816,
61 | "femur": 64.61701942138204,
62 | "tibia": -27.21279908137491,
63 | },
64 | 4: {
65 | "name": "left-back",
66 | "id": 4,
67 | "coxia": -27.925865837440085,
68 | "femur": 57.5357711909659,
69 | "tibia": -15.824751016445546,
70 | },
71 | 5: {
72 | "name": "right-back",
73 | "id": 5,
74 | "coxia": -34.07683786865073,
75 | "femur": 40.018917104647784,
76 | "tibia": -36.78650126914302,
77 | },
78 | }
79 |
--------------------------------------------------------------------------------
/tests/ik_cases/case2.py:
--------------------------------------------------------------------------------
1 | description = "IK Random Pose #2"
2 |
3 | # ********************************
4 | # Dimensions
5 | # ********************************
6 |
7 | given_dimensions = {
8 | "front": 76,
9 | "side": 114,
10 | "middle": 125,
11 | "coxia": 63,
12 | "femur": 142,
13 | "tibia": 171,
14 | }
15 |
16 | # ********************************
17 | # IK Parameters
18 | # ********************************
19 |
20 | given_ik_parameters = {
21 | "hip_stance": 10.5,
22 | "leg_stance": 25.5,
23 | "percent_x": 0.3,
24 | "percent_y": 0.05,
25 | "percent_z": -0.15,
26 | "rot_x": -1,
27 | "rot_y": 12.5,
28 | "rot_z": -8.5,
29 | }
30 |
31 | # ********************************
32 | # Poses
33 | # ********************************
34 |
35 | correct_poses = {
36 | 0: {
37 | "name": "right-middle",
38 | "id": 0,
39 | "coxia": 13.43107675540267,
40 | "femur": 77.7924770301091,
41 | "tibia": -60.647267530564136,
42 | },
43 | 1: {
44 | "name": "right-front",
45 | "id": 1,
46 | "coxia": 14.431630572348844,
47 | "femur": 66.33077021197852,
48 | "tibia": -57.05016213919879,
49 | },
50 | 2: {
51 | "name": "left-front",
52 | "id": 2,
53 | "coxia": 30.081030614307394,
54 | "femur": 11.619722581700444,
55 | "tibia": -1.6253249676582442,
56 | },
57 | 3: {
58 | "name": "left-middle",
59 | "id": 3,
60 | "coxia": 14.685705676958776,
61 | "femur": 3.177447435672022,
62 | "tibia": 4.800985597502901,
63 | },
64 | 4: {
65 | "name": "left-back",
66 | "id": 4,
67 | "coxia": -0.9115332668632732,
68 | "femur": 9.309907080761477,
69 | "tibia": -4.315425166528982,
70 | },
71 | 5: {
72 | "name": "right-back",
73 | "id": 5,
74 | "coxia": 14.382064236242854,
75 | "femur": 59.28098836470138,
76 | "tibia": -49.19348454681213,
77 | },
78 | }
79 |
--------------------------------------------------------------------------------
/tests/ik_cases/case3.py:
--------------------------------------------------------------------------------
1 | description = "IK Pose where x, y translation, rot y and z are close to extreme"
2 |
3 | # ********************************
4 | # Dimensions
5 | # ********************************
6 |
7 | given_dimensions = {
8 | "front": 73,
9 | "side": 100,
10 | "middle": 130,
11 | "coxia": 75,
12 | "femur": 129,
13 | "tibia": 154,
14 | }
15 |
16 | # ********************************
17 | # IK Parameters
18 | # ********************************
19 |
20 | given_ik_parameters = {
21 | "hip_stance": 10.5,
22 | "leg_stance": 30,
23 | "percent_x": 0.7,
24 | "percent_y": -0.4,
25 | "percent_z": 0.2,
26 | "rot_x": 1.5,
27 | "rot_y": -16,
28 | "rot_z": -14.5,
29 | }
30 |
31 | # ********************************
32 | # Poses
33 | # ********************************
34 |
35 | correct_poses = {
36 | 0: {
37 | "name": "right-middle",
38 | "id": 0,
39 | "coxia": 55.56073526445866,
40 | "femur": -24.206649398630788,
41 | "tibia": -8.608209643253758,
42 | },
43 | 1: {
44 | "name": "right-front",
45 | "id": 1,
46 | "coxia": 51.072817817160114,
47 | "femur": -5.7010123660724545,
48 | "tibia": -7.5181271777452565,
49 | },
50 | 2: {
51 | "name": "left-front",
52 | "id": 2,
53 | "coxia": 33.84438606600443,
54 | "femur": 30.76781937225195,
55 | "tibia": 16.92773639721497,
56 | },
57 | 3: {
58 | "name": "left-middle",
59 | "id": 3,
60 | "coxia": 13.117976527545807,
61 | "femur": 48.11622597324919,
62 | "tibia": -0.4754969993002618,
63 | },
64 | 4: {
65 | "name": "left-back",
66 | "id": 4,
67 | "coxia": -8.445805980297905,
68 | "femur": 53.09741126074167,
69 | "tibia": -21.001329834229722,
70 | },
71 | 5: {
72 | "name": "right-back",
73 | "id": 5,
74 | "coxia": 26.002763598408308,
75 | "femur": -17.792174423794933,
76 | "tibia": -25.026074825755416,
77 | },
78 | }
79 |
--------------------------------------------------------------------------------
/tests/test_ik.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | from hexapod.const import BASE_DIMENSIONS
3 | from hexapod.models import VirtualHexapod
4 | from hexapod.points import Vector
5 | from hexapod.ik_solver import ik_solver, ik_solver2
6 | from hexapod.ik_solver.shared import update_hexapod_points
7 |
8 | from tests.ik_cases import case1, case2, case3
9 | from tests.helpers import assert_poses_equal, assert_two_hexapods_equal
10 |
11 | CASES = [case1, case2, case3]
12 |
13 |
14 | def assert_ik_solver(ik_function, case):
15 | hexapod = VirtualHexapod(case.given_dimensions)
16 | result_poses, _ = ik_function(hexapod, case.given_ik_parameters)
17 | assert_poses_equal(result_poses, case.correct_poses, case.description)
18 |
19 |
20 | def assert_ik_points(case, assume_ground_targets):
21 | hexapod = VirtualHexapod(case.given_dimensions)
22 | hexapod_ik = deepcopy(hexapod)
23 | hexapod_k = deepcopy(hexapod)
24 |
25 | poses, _ = ik_solver2.inverse_kinematics_update(hexapod, case.given_ik_parameters)
26 |
27 | hexapod_ik.update(poses, assume_ground_targets)
28 | hexapod_k.update(case.correct_poses, assume_ground_targets)
29 |
30 | assert_two_hexapods_equal(hexapod_ik, hexapod_k, case.description)
31 |
32 |
33 | def test_sample_ik():
34 | for case in CASES:
35 | assert_ik_solver(ik_solver2.inverse_kinematics_update, case)
36 | assert_ik_solver(ik_solver.inverse_kinematics_update, case)
37 | assert_ik_solver(ik_solver2.inverse_kinematics_update, case)
38 | assert_ik_solver(ik_solver.inverse_kinematics_update, case)
39 |
40 |
41 | def test_points_ik2_assume_ground_targets():
42 | for case in CASES:
43 | assert_ik_points(case, True)
44 |
45 |
46 | def test_points_ik2_dont_assume_ground_targets():
47 | for case in CASES:
48 | assert_ik_points(case, False)
49 |
50 |
51 | def test_shared_set_points():
52 | points = [
53 | Vector(1, 2, 3, "a"),
54 | Vector(1, 2, 3, "b"),
55 | Vector(1, 2, 3, "c"),
56 | Vector(1, 2, 3, "d"),
57 | ]
58 |
59 | vh = VirtualHexapod(BASE_DIMENSIONS)
60 | update_hexapod_points(vh, 1, points)
61 | for leg_point, point in zip(vh.legs[1].all_points, points):
62 | assert leg_point is point
63 |
--------------------------------------------------------------------------------
/pages/helpers.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | import json
3 | import dash_core_components as dcc
4 | from hexapod.const import (
5 | BASE_PLOTTER,
6 | BASE_POSE,
7 | BASE_IK_PARAMS,
8 | BASE_DIMENSIONS,
9 | NAMES_LEG,
10 | )
11 |
12 | NEW_POSES = deepcopy(BASE_POSE)
13 | POSES_MSG_HEADER = """
14 | +----------------+------------+------------+------------+
15 | | leg name | coxia | femur | tibia |
16 | +----------------+------------+------------+------------+"""
17 | POSES_MSG_LAST_ROW = "\n+----------------+------------+------------+------------+"
18 |
19 |
20 | def make_pose(alpha, beta, gamma, poses=NEW_POSES):
21 |
22 | for k in poses.keys():
23 | poses[k] = {
24 | "id": k,
25 | "name": NAMES_LEG[k],
26 | "coxia": alpha,
27 | "femur": beta,
28 | "tibia": gamma,
29 | }
30 | return poses
31 |
32 |
33 | def change_camera_view(figure, relayout_data):
34 | if relayout_data and "scene.camera" in relayout_data:
35 | camera = relayout_data["scene.camera"]
36 | BASE_PLOTTER.change_camera_view(figure, camera)
37 |
38 | return figure
39 |
40 |
41 | def load_params(params_json, params_type):
42 | try:
43 | params = json.loads(params_json)
44 | except Exception as e:
45 | print(f"Error loading json of type {params_type}. {e} | {params_json}")
46 |
47 | if params_type == "dims":
48 | return BASE_DIMENSIONS
49 | if params_type == "pose":
50 | return BASE_POSE
51 | if params_type == "ik":
52 | return BASE_IK_PARAMS
53 |
54 | raise Exception(
55 | f'params_type must be "dims", "pose" or "ik", not {params_type}'
56 | ) from e
57 |
58 | return params
59 |
60 |
61 | def make_monospace(text):
62 | return dcc.Markdown(f" ```{text}")
63 |
64 |
65 | def make_poses_message(poses):
66 | message = POSES_MSG_HEADER
67 |
68 | for pose in poses.values():
69 | name = pose["name"]
70 | coxia = pose["coxia"]
71 | femur = pose["femur"]
72 | tibia = pose["tibia"]
73 | row = f"\n| {name:14} | {coxia:<+10.2f} | {femur:<+10.2f} | {tibia:<+10.2f} |"
74 | message += row
75 |
76 | return make_monospace(message + POSES_MSG_LAST_ROW)
77 |
78 |
79 | def make_alert_message(alert):
80 | return make_monospace(f"❗❗❗ALERT❗❗❗\n⚠️ {alert} 🔴")
81 |
--------------------------------------------------------------------------------
/hexapod/ground_contact_solver/shared.py:
--------------------------------------------------------------------------------
1 | from math import isclose
2 | from hexapod.points import (
3 | Vector,
4 | dot,
5 | cross,
6 | vector_from_to,
7 | )
8 |
9 | # Prioritize legs that are not adjacent to each other
10 | SOME_LEG_TRIOS = [
11 | (0, 1, 3),
12 | (0, 1, 4),
13 | (0, 2, 3),
14 | (0, 2, 4),
15 | (0, 2, 5),
16 | (0, 3, 4),
17 | (0, 3, 5),
18 | (1, 2, 4),
19 | (1, 2, 5),
20 | (1, 3, 4),
21 | (1, 3, 5),
22 | (1, 4, 5),
23 | (2, 3, 5),
24 | (2, 4, 5),
25 | ]
26 |
27 | ADJACENT_LEG_TRIOS = [
28 | (0, 1, 2),
29 | (1, 2, 3),
30 | (2, 3, 4),
31 | (3, 4, 5),
32 | (0, 4, 5),
33 | (0, 1, 5),
34 | ]
35 |
36 | LEG_TRIOS = SOME_LEG_TRIOS + ADJACENT_LEG_TRIOS
37 |
38 |
39 | # math.stackexchange.com/questions/544946/
40 | # determine-if-projection-of-3d-point-onto-plane-is-within-a-triangle
41 | # gamedev.stackexchange.com/questions/23743/
42 | # whats-the-most-efficient-way-to-find-barycentric-coordinates
43 | # en.wikipedia.org/wiki/Barycentric_coordinate_system
44 | def is_stable(p1, p2, p3, tol=0.001):
45 | """
46 | Determines stability of the pose.
47 | Determine if projection of 3D point p
48 | onto the plane defined by p1, p2, p3
49 | is within a triangle defined by p1, p2, p3.
50 | """
51 | p = Vector(0, 0, 0)
52 | u = vector_from_to(p1, p2)
53 | v = vector_from_to(p1, p3)
54 | n = cross(u, v)
55 | w = vector_from_to(p1, p)
56 | n2 = dot(n, n)
57 | beta = dot(cross(u, w), n) / n2
58 | gamma = dot(cross(w, v), n) / n2
59 | alpha = 1 - gamma - beta
60 | # then coordinate of the projected point (p_) of point p
61 | # p_ = alpha * p1 + beta * p2 + gamma * p3
62 | min_val = -tol
63 | max_val = 1 + tol
64 | cond1 = min_val <= alpha <= max_val
65 | cond2 = min_val <= beta <= max_val
66 | cond3 = min_val <= gamma <= max_val
67 | return cond1 and cond2 and cond3
68 |
69 |
70 | def is_lower(point, height, n, tol=1):
71 | _height = -dot(n, point)
72 | return _height > height + tol
73 |
74 |
75 | def find_legs_on_ground(legs, n, height, tol=1):
76 | legs_on_ground = []
77 | for leg in legs:
78 | for point in reversed(leg.all_points[1:]):
79 | _height = -dot(n, point)
80 | if isclose(height, _height, abs_tol=tol):
81 | legs_on_ground.append(leg)
82 | break
83 |
84 | return legs_on_ground
85 |
--------------------------------------------------------------------------------
/widgets/pose_control/joint_widget_maker.py:
--------------------------------------------------------------------------------
1 | # Used to build the widgets for changing the joint angles
2 | import dash_core_components as dcc
3 | import dash_daq
4 | from hexapod.const import NAMES_JOINT, NAMES_LEG
5 | from settings import (
6 | ALPHA_MAX_ANGLE,
7 | BETA_MAX_ANGLE,
8 | GAMMA_MAX_ANGLE,
9 | UPDATE_MODE,
10 | )
11 | from style_settings import (
12 | NUMBER_INPUT_STYLE,
13 | SLIDER_THEME,
14 | SLIDER_HANDLE_COLOR,
15 | SLIDER_COLOR,
16 | )
17 |
18 | max_angles = {
19 | "coxia": ALPHA_MAX_ANGLE,
20 | "femur": BETA_MAX_ANGLE,
21 | "tibia": GAMMA_MAX_ANGLE,
22 | }
23 |
24 |
25 | # widget id format:
26 | # 'widget' + '-' + leg_x + '-' + leg_y + '-' leg_joint
27 | # leg_x = ['left', 'right']
28 | # leg_y = ['front', 'middle', 'back']
29 | # leg_joint = ['coxia', 'femur', 'tibia']
30 | # input dictionary structure
31 | # all_joint_widgets['left-front']['coxia'] = joint_widget
32 | # all_joint_widgets['right-middle']['femur'] = joint_widget
33 | def make_all_joint_widgets(joint_input_function):
34 | all_joint_widgets = {}
35 |
36 | for leg_name in NAMES_LEG:
37 | leg_joint_widget = {}
38 |
39 | for joint_name in NAMES_JOINT:
40 | widget_id = "widget-{}-{}".format(leg_name, joint_name)
41 | leg_joint_widget[joint_name] = joint_input_function(
42 | widget_id, max_angles[joint_name]
43 | )
44 |
45 | all_joint_widgets[leg_name] = leg_joint_widget
46 |
47 | return all_joint_widgets
48 |
49 |
50 | def make_daq_slider(widget_id, max_angle):
51 | _, _, _, angle = widget_id.split("-")
52 |
53 | handle_style = {
54 | "showCurrentValue": True,
55 | "color": SLIDER_HANDLE_COLOR,
56 | "label": angle,
57 | }
58 |
59 | return dash_daq.Slider( # pylint: disable=not-callable
60 | id=widget_id,
61 | min=-max_angle,
62 | max=max_angle,
63 | value=1.5,
64 | step=1.5,
65 | size=80,
66 | vertical=True,
67 | updatemode=UPDATE_MODE,
68 | handleLabel=handle_style,
69 | color={"default": SLIDER_COLOR},
70 | theme=SLIDER_THEME,
71 | )
72 |
73 |
74 | def make_slider(widget_id, max_angle):
75 | slider_marks = {tick: str(tick) for tick in [-45, 0, 45]}
76 | return dcc.Slider(
77 | id=widget_id, min=-max_angle, max=max_angle, marks=slider_marks, value=0, step=5
78 | )
79 |
80 |
81 | def make_number_widget(widget_id, max_angle):
82 | return dcc.Input(
83 | id=widget_id,
84 | type="number",
85 | value=0.0,
86 | min=-max_angle,
87 | max=max_angle,
88 | style=NUMBER_INPUT_STYLE,
89 | )
90 |
--------------------------------------------------------------------------------
/pages/page_inverse.py:
--------------------------------------------------------------------------------
1 | import json
2 | from dash.dependencies import Output
3 | from app import app
4 | from settings import RECOMPUTE_HEXAPOD
5 | from hexapod.models import VirtualHexapod
6 | from hexapod.const import BASE_PLOTTER
7 | from hexapod.ik_solver.ik_solver2 import inverse_kinematics_update
8 | from hexapod.ik_solver.recompute_hexapod import recompute_hexapod
9 | from widgets.ik_ui import IK_WIDGETS_SECTION, IK_CALLBACK_INPUTS
10 | from pages import helpers, shared
11 |
12 |
13 | # ......................
14 | # Page layout
15 | # ......................
16 |
17 | GRAPH_ID = "graph-inverse"
18 | MESSAGE_SECTION_ID = "message-inverse"
19 | PARAMETERS_SECTION_ID = "parameters-inverse"
20 |
21 | sidebar = shared.make_standard_page_sidebar(
22 | MESSAGE_SECTION_ID, PARAMETERS_SECTION_ID, IK_WIDGETS_SECTION
23 | )
24 |
25 | layout = shared.make_standard_page_layout(GRAPH_ID, sidebar)
26 |
27 |
28 | # ......................
29 | # Update page
30 | # ......................
31 |
32 | outputs, inputs, states = shared.make_standard_page_callback_params(
33 | GRAPH_ID, PARAMETERS_SECTION_ID, MESSAGE_SECTION_ID
34 | )
35 |
36 |
37 | @app.callback(outputs, inputs, states)
38 | def update_inverse_page(dimensions_json, ik_parameters_json, relayout_data, figure):
39 |
40 | dimensions = helpers.load_params(dimensions_json, "dims")
41 | ik_parameters = helpers.load_params(ik_parameters_json, "ik")
42 | hexapod = VirtualHexapod(dimensions)
43 |
44 | try:
45 | poses, hexapod = inverse_kinematics_update(hexapod, ik_parameters)
46 | except Exception as alert:
47 | return figure, helpers.make_alert_message(alert)
48 |
49 | if RECOMPUTE_HEXAPOD:
50 | try:
51 | hexapod = recompute_hexapod(dimensions, ik_parameters, poses)
52 | except Exception as alert:
53 | return figure, helpers.make_alert_message(alert)
54 |
55 | BASE_PLOTTER.update(figure, hexapod)
56 | helpers.change_camera_view(figure, relayout_data)
57 | return figure, helpers.make_poses_message(poses)
58 |
59 |
60 | # ......................
61 | # Update parameters
62 | # ......................
63 |
64 | output_parameter = Output(PARAMETERS_SECTION_ID, "children")
65 | input_parameters = IK_CALLBACK_INPUTS
66 |
67 |
68 | @app.callback(output_parameter, input_parameters)
69 | def update_ik_parameters(
70 | hip_stance, leg_stance, percent_x, percent_y, percent_z, rot_x, rot_y, rot_z
71 | ):
72 |
73 | return json.dumps(
74 | {
75 | "hip_stance": hip_stance or 0,
76 | "leg_stance": leg_stance or 0,
77 | "percent_x": percent_x or 0,
78 | "percent_y": percent_y or 0,
79 | "percent_z": percent_z or 0,
80 | "rot_x": rot_x or 0,
81 | "rot_y": rot_y or 0,
82 | "rot_z": rot_z or 0,
83 | }
84 | )
85 |
--------------------------------------------------------------------------------
/index.py:
--------------------------------------------------------------------------------
1 | import dash_core_components as dcc
2 | import dash_html_components as html
3 | from dash.dependencies import Input, Output
4 | from texts import (
5 | URL_KOFI,
6 | URL_REPO,
7 | KINEMATICS_PAGE_PATH,
8 | IK_PAGE_PATH,
9 | PATTERNS_PAGE_PATH,
10 | ROOT_PATH,
11 | )
12 | from settings import DEBUG_MODE
13 | from style_settings import GLOBAL_PAGE_STYLE
14 | from app import app
15 | from pages import page_inverse, page_kinematics, page_patterns, page_landing
16 |
17 | server = app.server
18 |
19 | # ....................
20 | # Navigation partials
21 | # ....................
22 | icon_link_style = {"margin": "0 0 0 0.5em"}
23 |
24 | div_header = html.Div(
25 | [
26 | html.A(html.H6("👾"), href=URL_REPO, target="_blank", style=icon_link_style),
27 | html.A(html.H6("☕"), href=URL_KOFI, target="_blank", style=icon_link_style),
28 | dcc.Link(html.H6("●"), href=PATTERNS_PAGE_PATH, style=icon_link_style),
29 | dcc.Link(html.H6("●"), href=IK_PAGE_PATH, style=icon_link_style),
30 | dcc.Link(html.H6("●"), href=KINEMATICS_PAGE_PATH, style=icon_link_style),
31 | dcc.Link(html.H6("●"), href=ROOT_PATH, style=icon_link_style),
32 | ],
33 | style={"display": "flex", "flex-direction": "row"},
34 | )
35 |
36 | div_footer = html.Div(
37 | [
38 | html.A("👾 Source Code", href=URL_REPO, target="_blank"),
39 | html.Br(),
40 | html.A("☕ Buy Mithi coffee", href=URL_KOFI, target="_blank"),
41 | html.Br(),
42 | dcc.Link("● Leg Patterns", href=PATTERNS_PAGE_PATH),
43 | html.Br(),
44 | dcc.Link("● Inverse Kinematics", href=IK_PAGE_PATH),
45 | html.Br(),
46 | dcc.Link("● Kinematics", href=KINEMATICS_PAGE_PATH),
47 | html.Br(),
48 | dcc.Link("● Root", href=ROOT_PATH),
49 | html.Br(),
50 | ],
51 | )
52 |
53 | # ....................
54 | # Page layout
55 | # ....................
56 | app.layout = html.Div(
57 | [
58 | div_header,
59 | dcc.Location(id="url", refresh=False),
60 | html.Div(id="page-content"),
61 | div_footer,
62 | ],
63 | style=GLOBAL_PAGE_STYLE,
64 | )
65 |
66 |
67 | # ....................
68 | # URL redirection
69 | # ....................
70 | PAGES = {
71 | IK_PAGE_PATH: page_inverse.layout,
72 | KINEMATICS_PAGE_PATH: page_kinematics.layout,
73 | PATTERNS_PAGE_PATH: page_patterns.layout,
74 | ROOT_PATH: page_landing.layout,
75 | }
76 |
77 |
78 | # ....................
79 | # Callback to display page given URL
80 | # ....................
81 | @app.callback(Output("page-content", "children"), [Input("url", "pathname")])
82 | def display_page(pathname):
83 | try:
84 | return PAGES[pathname]
85 | except KeyError:
86 | return PAGES[ROOT_PATH]
87 |
88 |
89 | # ....................
90 | # Run server
91 | # ....................
92 | if __name__ == "__main__":
93 | app.run_server(
94 | debug=DEBUG_MODE, dev_tools_ui=DEBUG_MODE, dev_tools_props_check=DEBUG_MODE
95 | )
96 |
--------------------------------------------------------------------------------
/pages/shared.py:
--------------------------------------------------------------------------------
1 | import json
2 | import dash_core_components as dcc
3 | from dash.dependencies import Output, Input, State
4 | import dash_html_components as html
5 | from app import app
6 | from settings import (
7 | UI_SIDEBAR_WIDTH,
8 | UI_GRAPH_WIDTH,
9 | UI_GRAPH_HEIGHT,
10 | )
11 | from widgets.dimensions_ui import DIMENSION_CALLBACK_INPUTS, DIMENSIONS_WIDGETS_SECTION
12 | from hexapod.const import BASE_FIGURE
13 |
14 |
15 | # ......................
16 | # Update hexapod dimensions callback
17 | # ......................
18 |
19 | DIMENSIONS_HIDDEN_SECTION_ID = "hexapod-dimensions-values"
20 | DIMENSIONS_HIDDEN_SECTION = html.Div(
21 | id=DIMENSIONS_HIDDEN_SECTION_ID, style={"display": "none"}
22 | )
23 | DIMS_JSON_CALLBACK_INPUT = Input(DIMENSIONS_HIDDEN_SECTION_ID, "children")
24 | DIMS_JSON_CALLBACK_OUTPUT = Output(DIMENSIONS_HIDDEN_SECTION_ID, "children")
25 |
26 |
27 | @app.callback(DIMS_JSON_CALLBACK_OUTPUT, DIMENSION_CALLBACK_INPUTS)
28 | def update_dimensions(front, side, middle, coxia, femur, tibia):
29 | dimensions = {
30 | "front": front or 0,
31 | "side": side or 0,
32 | "middle": middle or 0,
33 | "coxia": coxia or 0,
34 | "femur": femur or 0,
35 | "tibia": tibia or 0,
36 | }
37 | return json.dumps(dimensions)
38 |
39 |
40 | # ......................
41 | # Make uniform layout
42 | # Graph on the right, controls on the left
43 | # ......................
44 |
45 |
46 | def make_standard_page_layout(graph_id, sidebar_sections):
47 | sidebar = html.Div(sidebar_sections, style={"width": UI_SIDEBAR_WIDTH})
48 | graph = dcc.Graph(
49 | id=graph_id,
50 | figure=BASE_FIGURE,
51 | style={"width": UI_GRAPH_WIDTH, "height": UI_GRAPH_HEIGHT},
52 | )
53 |
54 | layout = html.Div([sidebar, graph], style={"display": "flex"})
55 | return layout
56 |
57 |
58 | # ......................
59 | # Make standard sidebar
60 | # ......................
61 |
62 |
63 | def make_standard_page_sidebar(
64 | message_section_id, params_hidden_section_id, params_widgets_section
65 | ):
66 | params_hidden_section = html.Div(
67 | id=params_hidden_section_id, style={"display": "none"}
68 | )
69 | message_section = html.Div(id=message_section_id)
70 |
71 | return [
72 | DIMENSIONS_WIDGETS_SECTION,
73 | params_widgets_section,
74 | message_section,
75 | DIMENSIONS_HIDDEN_SECTION,
76 | params_hidden_section,
77 | ]
78 |
79 |
80 | # ......................
81 | # Make outputs, inputs, and states for page update callbacks
82 | # .....................
83 |
84 |
85 | def make_standard_page_callback_params(graph_id, params_section_id, message_section_id):
86 |
87 | message_callback_output = Output(message_section_id, "children")
88 | params_json_callback_input = Input(params_section_id, "children")
89 | outputs = [Output(graph_id, "figure"), message_callback_output]
90 | inputs = [DIMS_JSON_CALLBACK_INPUT, params_json_callback_input]
91 | states = [State(graph_id, "relayoutData"), State(graph_id, "figure")]
92 | return outputs, inputs, states
93 |
--------------------------------------------------------------------------------
/pages/page_kinematics.py:
--------------------------------------------------------------------------------
1 | import json
2 | from dash.dependencies import Output
3 | from app import app
4 | from settings import WHICH_POSE_CONTROL_UI
5 | from hexapod.models import VirtualHexapod
6 | from hexapod.const import BASE_PLOTTER
7 | from widgets.pose_control.components import KINEMATICS_CALLBACK_INPUTS
8 | from pages import helpers, shared
9 |
10 | if WHICH_POSE_CONTROL_UI == 1:
11 | from widgets.pose_control.generic_daq_slider_ui import KINEMATICS_WIDGETS_SECTION
12 | elif WHICH_POSE_CONTROL_UI == 2:
13 | from widgets.pose_control.generic_slider_ui import KINEMATICS_WIDGETS_SECTION
14 | else:
15 | from widgets.pose_control.generic_input_ui import KINEMATICS_WIDGETS_SECTION
16 |
17 | # ......................
18 | # Page layout
19 | # ......................
20 |
21 | GRAPH_ID = "graph-kinematics"
22 | MESSAGE_SECTION_ID = "message-kinematics"
23 | PARAMETERS_SECTION_ID = "parameters-kinematics"
24 |
25 | sidebar = shared.make_standard_page_sidebar(
26 | MESSAGE_SECTION_ID, PARAMETERS_SECTION_ID, KINEMATICS_WIDGETS_SECTION
27 | )
28 |
29 | layout = shared.make_standard_page_layout(GRAPH_ID, sidebar)
30 |
31 |
32 | # ......................
33 | # Update page
34 | # ......................
35 |
36 | outputs, inputs, states = shared.make_standard_page_callback_params(
37 | GRAPH_ID, PARAMETERS_SECTION_ID, MESSAGE_SECTION_ID
38 | )
39 |
40 |
41 | @app.callback(outputs, inputs, states)
42 | def update_kinematics_page(dimensions_json, poses_json, relayout_data, figure):
43 |
44 | dimensions = helpers.load_params(dimensions_json, "dims")
45 | poses = helpers.load_params(poses_json, "pose")
46 | hexapod = VirtualHexapod(dimensions)
47 |
48 | try:
49 | hexapod.update(poses, assume_ground_targets=False)
50 | except Exception as alert:
51 | return figure, helpers.make_alert_message(alert)
52 |
53 | BASE_PLOTTER.update(figure, hexapod)
54 | helpers.change_camera_view(figure, relayout_data)
55 | return figure, ""
56 |
57 |
58 | # ......................
59 | # Update parameters
60 | # ......................
61 |
62 |
63 | output_parameter = Output(PARAMETERS_SECTION_ID, "children")
64 | input_parameters = KINEMATICS_CALLBACK_INPUTS
65 |
66 | # fmt: off
67 |
68 |
69 | @app.callback(output_parameter, input_parameters)
70 | def update_poses(
71 | rmc, rmf, rmt,
72 | rfc, rff, rft,
73 | lfc, lff, lft,
74 | lmc, lmf, lmt,
75 | lbc, lbf, lbt,
76 | rbc, rbf, rbt,
77 | ):
78 |
79 | return json.dumps({
80 | 0: {"coxia": rmc or 0, "femur": rmf or 0, "tibia": rmt or 0, "name": "right-middle", "id": 0},
81 | 1: {"coxia": rfc or 0, "femur": rff or 0, "tibia": rft or 0, "name": "right-front", "id": 1},
82 | 2: {"coxia": lfc or 0, "femur": lff or 0, "tibia": lft or 0, "name": "left-front", "id": 2},
83 | 3: {"coxia": lmc or 0, "femur": lmf or 0, "tibia": lmt or 0, "name": "left-middle", "id": 3},
84 | 4: {"coxia": lbc or 0, "femur": lbf or 0, "tibia": lbt or 0, "name": "left-back", "id": 4},
85 | 5: {"coxia": rbc or 0, "femur": rbf or 0, "tibia": rbt or 0, "name": "right-back", "id": 5},
86 | })
87 |
88 | # fmt: on
89 |
--------------------------------------------------------------------------------
/style_settings.py:
--------------------------------------------------------------------------------
1 | DARKMODE = True
2 |
3 | DARK_CSS_PATH = "https://mithi.github.io/hexapod-robot-simulator/dark.css"
4 | LIGHT_CSS_PATH = "https://mithi.github.io/hexapod-robot-simulator/light.css"
5 |
6 | EXTERNAL_STYLESHEETS = [DARK_CSS_PATH]
7 | if not DARKMODE:
8 | EXTERNAL_STYLESHEETS = [LIGHT_CSS_PATH]
9 |
10 |
11 | # ***************************************
12 | # GLOBAL PAGE STYLE
13 | # ***************************************
14 |
15 | DARK_BG_COLOR = "#222f3e"
16 | DARK_FONT_COLOR = "#32ff7e"
17 |
18 | GLOBAL_PAGE_STYLE = {
19 | "background": DARK_BG_COLOR,
20 | "color": DARK_FONT_COLOR,
21 | "padding": "0em",
22 | }
23 |
24 | if not DARKMODE:
25 | GLOBAL_PAGE_STYLE = {"background": "#ffffff", "color": "#2c3e50", "padding": "0em"}
26 |
27 |
28 | # ***************************************
29 | # NUMBER FIELD INPUT WIDGET
30 | # ***************************************
31 |
32 | NUMBER_INPUT_STYLE = {
33 | "marginRight": "5%",
34 | "width": "95%",
35 | "marginBottom": "5%",
36 | "borderRadius": "10px",
37 | "border": "solid 1px",
38 | "fontFamily": "Courier New",
39 | }
40 |
41 | if DARKMODE:
42 | NUMBER_INPUT_STYLE["backgroundColor"] = "#2c3e50"
43 | NUMBER_INPUT_STYLE["color"] = "#2ecc71"
44 | NUMBER_INPUT_STYLE["borderColor"] = "#2980b9"
45 |
46 |
47 | # ***************************************
48 | # DAQ SLIDER INPUT WIDGET
49 | # ***************************************
50 |
51 | IK_SLIDER_SIZE = 100
52 |
53 | SLIDER_THEME = {
54 | "dark": DARKMODE,
55 | "detail": "#ffffff",
56 | "primary": "#ffffff",
57 | "secondary": "#ffffff",
58 | }
59 |
60 | SLIDER_HANDLE_COLOR = "#2ecc71"
61 | SLIDER_COLOR = "#FC427B"
62 |
63 | if not DARKMODE:
64 | SLIDER_HANDLE_COLOR = "#2c3e50"
65 | SLIDER_COLOR = "#8e44ad"
66 |
67 |
68 | # ***************************************
69 | # HEXAPOD GRAPH
70 | # ***************************************
71 |
72 | BODY_MESH_COLOR = "#ff6348"
73 | BODY_MESH_OPACITY = 0.3
74 | BODY_COLOR = "#FC427B"
75 | BODY_OUTLINE_WIDTH = 12
76 | COG_COLOR = "#32ff7e"
77 | COG_SIZE = 14
78 | HEAD_SIZE = 14
79 | LEG_COLOR = "#EE5A24" # "#b71540"
80 | LEG_OUTLINE_WIDTH = 10
81 | SUPPORT_POLYGON_MESH_COLOR = "#3c6382"
82 | SUPPORT_POLYGON_MESH_OPACITY = 0.2
83 | LEGENDS_BG_COLOR = "rgba(44, 62, 80, 0.8)"
84 | AXIS_ZERO_LINE_COLOR = "#079992"
85 | PAPER_BG_COLOR = "#222f3e"
86 | GROUND_COLOR = "#0a3d62"
87 | LEGEND_FONT_COLOR = "#2ecc71"
88 |
89 | if not DARKMODE:
90 | BODY_MESH_COLOR = "#8e44ad"
91 | BODY_MESH_OPACITY = 0.9
92 | BODY_COLOR = "#8e44ad"
93 | BODY_OUTLINE_WIDTH = 10
94 | COG_COLOR = "#2c3e50"
95 | COG_SIZE = 15
96 | HEAD_COLOR = "#8e44ad"
97 | HEAD_SIZE = 12
98 | LEG_COLOR = "#2c3e50"
99 | LEG_OUTLINE_WIDTH = 10
100 | SUPPORT_POLYGON_MESH_COLOR = "#ffa801"
101 | SUPPORT_POLYGON_MESH_OPACITY = 0.3
102 | LEGENDS_BG_COLOR = "rgba(255, 255, 255, 0.5)"
103 | AXIS_ZERO_LINE_COLOR = "#ffa801"
104 | PAPER_BG_COLOR = "white"
105 | GROUND_COLOR = "rgb(240, 240, 240)"
106 | LEGEND_FONT_COLOR = "#34495e"
107 |
--------------------------------------------------------------------------------
/tests/pattern_cases/case1.py:
--------------------------------------------------------------------------------
1 | from hexapod.points import Vector
2 |
3 | description = "Patterns Random Pose #1"
4 | alpha = 42
5 | beta = 66
6 | gamma = -34.5
7 |
8 | # ********************************
9 | # Dimensions
10 | # ********************************
11 |
12 | given_dimensions = {
13 | "front": 76,
14 | "side": 92,
15 | "middle": 123,
16 | "coxia": 58,
17 | "femur": 177,
18 | "tibia": 151,
19 | }
20 |
21 | # ********************************
22 | # Correct Body Vectors
23 | # ********************************
24 |
25 | correct_body_points = [
26 | Vector(x=+123.00, y=+0.00, z=+0.00, name="right-middle"),
27 | Vector(x=+76.00, y=+92.00, z=+0.00, name="right-front"),
28 | Vector(x=-76.00, y=+92.00, z=+0.00, name="left-front"),
29 | Vector(x=-123.00, y=+0.00, z=+0.00, name="left-middle"),
30 | Vector(x=-76.00, y=-92.00, z=+0.00, name="left-back"),
31 | Vector(x=+76.00, y=-92.00, z=+0.00, name="right-back"),
32 | Vector(x=+0.00, y=+0.00, z=+0.00, name="center-of-gravity"),
33 | Vector(x=+0.00, y=+92.00, z=+0.00, name="head"),
34 | ]
35 |
36 |
37 | # ********************************
38 | # Correct Leg Vectors
39 | # ********************************
40 |
41 | leg0_points = [
42 | Vector(x=+123.00, y=+0.00, z=+0.00, name="right-middle-body-contact"),
43 | Vector(x=+166.10, y=+38.81, z=+0.00, name="right-middle-coxia"),
44 | Vector(x=+219.60, y=+86.98, z=+161.70, name="right-middle-femur"),
45 | Vector(x=+278.24, y=+139.77, z=+32.95, name="right-middle-tibia"),
46 | ]
47 |
48 | leg1_points = [
49 | Vector(x=+76.00, y=+92.00, z=+0.00, name="right-front-body-contact"),
50 | Vector(x=+79.04, y=+149.92, z=+0.00, name="right-front-coxia"),
51 | Vector(x=+82.80, y=+221.81, z=+161.70, name="right-front-femur"),
52 | Vector(x=+86.93, y=+300.60, z=+32.95, name="right-front-tibia"),
53 | ]
54 |
55 | leg2_points = [
56 | Vector(x=-76.00, y=+92.00, z=+0.00, name="left-front-body-contact"),
57 | Vector(x=-133.92, y=+95.04, z=+0.00, name="left-front-coxia"),
58 | Vector(x=-205.81, y=+98.80, z=+161.70, name="left-front-femur"),
59 | Vector(x=-284.60, y=+102.93, z=+32.95, name="left-front-tibia"),
60 | ]
61 |
62 | leg3_points = [
63 | Vector(x=-123.00, y=+0.00, z=+0.00, name="left-middle-body-contact"),
64 | Vector(x=-166.10, y=-38.81, z=+0.00, name="left-middle-coxia"),
65 | Vector(x=-219.60, y=-86.98, z=+161.70, name="left-middle-femur"),
66 | Vector(x=-278.24, y=-139.77, z=+32.95, name="left-middle-tibia"),
67 | ]
68 |
69 | leg4_points = [
70 | Vector(x=-76.00, y=-92.00, z=+0.00, name="left-back-body-contact"),
71 | Vector(x=-79.04, y=-149.92, z=+0.00, name="left-back-coxia"),
72 | Vector(x=-82.80, y=-221.81, z=+161.70, name="left-back-femur"),
73 | Vector(x=-86.93, y=-300.60, z=+32.95, name="left-back-tibia"),
74 | ]
75 |
76 | leg5_points = [
77 | Vector(x=+76.00, y=-92.00, z=+0.00, name="right-back-body-contact"),
78 | Vector(x=+133.92, y=-95.04, z=+0.00, name="right-back-coxia"),
79 | Vector(x=+205.81, y=-98.80, z=+161.70, name="right-back-femur"),
80 | Vector(x=+284.60, y=-102.93, z=+32.95, name="right-back-tibia"),
81 | ]
82 |
83 |
84 | correct_leg_points = [
85 | leg0_points,
86 | leg1_points,
87 | leg2_points,
88 | leg3_points,
89 | leg4_points,
90 | leg5_points,
91 | ]
92 |
--------------------------------------------------------------------------------
/tests/pattern_cases/case2.py:
--------------------------------------------------------------------------------
1 | from hexapod.points import Vector
2 |
3 | description = "Patterns Random Pose #2"
4 | alpha = -28.5
5 | beta = 72
6 | gamma = -54
7 |
8 | # ********************************
9 | # Dimensions
10 | # ********************************
11 |
12 | given_dimensions = {
13 | "front": 65,
14 | "side": 101,
15 | "middle": 122,
16 | "coxia": 83,
17 | "femur": 108,
18 | "tibia": 177,
19 | }
20 |
21 | # ********************************
22 | # Correct Body Vectors
23 | # ********************************
24 |
25 | correct_body_points = [
26 | Vector(x=+116.86, y=+35.03, z=+65.62, name="right-middle"),
27 | Vector(x=+33.27, y=+115.41, z=+65.62, name="right-front"),
28 | Vector(x=-91.26, y=+78.09, z=+65.62, name="left-front"),
29 | Vector(x=-116.86, y=-35.03, z=+65.62, name="left-middle"),
30 | Vector(x=-33.27, y=-115.41, z=+65.62, name="left-back"),
31 | Vector(x=+91.26, y=-78.09, z=+65.62, name="right-back"),
32 | Vector(x=+0.00, y=+0.00, z=+65.62, name="center-of-gravity"),
33 | Vector(x=-29.00, y=+96.75, z=+65.62, name="head"),
34 | ]
35 |
36 | # ********************************
37 | # Correct Leg Vectors
38 | # ********************************
39 |
40 | leg0_points = [
41 | Vector(x=+116.86, y=+35.03, z=+65.62, name="right-middle-body-contact"),
42 | Vector(x=+198.11, y=+18.03, z=+65.62, name="right-middle-coxia"),
43 | Vector(x=+230.77, y=+11.20, z=+168.34, name="right-middle-femur"),
44 | Vector(x=+284.31, y=+0.00, z=+0.00, name="right-middle-tibia"),
45 | ]
46 |
47 | leg1_points = [
48 | Vector(x=+33.27, y=+115.41, z=+65.62, name="right-front-body-contact"),
49 | Vector(x=+102.73, y=+160.84, z=+65.62, name="right-front-coxia"),
50 | Vector(x=+130.66, y=+179.11, z=+168.34, name="right-front-femur"),
51 | Vector(x=+176.44, y=+209.04, z=+0.00, name="right-front-tibia"),
52 | ]
53 |
54 | leg2_points = [
55 | Vector(x=-91.26, y=+78.09, z=+65.62, name="left-front-body-contact"),
56 | Vector(x=-136.69, y=+147.55, z=+65.62, name="left-front-coxia"),
57 | Vector(x=-154.96, y=+175.48, z=+168.34, name="left-front-femur"),
58 | Vector(x=-184.90, y=+221.26, z=+0.00, name="left-front-tibia"),
59 | ]
60 |
61 | leg3_points = [
62 | Vector(x=-116.86, y=-35.03, z=+65.62, name="left-middle-body-contact"),
63 | Vector(x=-198.11, y=-18.03, z=+65.62, name="left-middle-coxia"),
64 | Vector(x=-230.77, y=-11.20, z=+168.34, name="left-middle-femur"),
65 | Vector(x=-284.31, y=+0.00, z=+0.00, name="left-middle-tibia"),
66 | ]
67 |
68 | leg4_points = [
69 | Vector(x=-33.27, y=-115.41, z=+65.62, name="left-back-body-contact"),
70 | Vector(x=-102.73, y=-160.84, z=+65.62, name="left-back-coxia"),
71 | Vector(x=-130.66, y=-179.11, z=+168.34, name="left-back-femur"),
72 | Vector(x=-176.44, y=-209.04, z=+0.00, name="left-back-tibia"),
73 | ]
74 |
75 | leg5_points = [
76 | Vector(x=+91.26, y=-78.09, z=+65.62, name="right-back-body-contact"),
77 | Vector(x=+136.69, y=-147.55, z=+65.62, name="right-back-coxia"),
78 | Vector(x=+154.96, y=-175.48, z=+168.34, name="right-back-femur"),
79 | Vector(x=+184.90, y=-221.26, z=+0.00, name="right-back-tibia"),
80 | ]
81 |
82 | correct_leg_points = [
83 | leg0_points,
84 | leg1_points,
85 | leg2_points,
86 | leg3_points,
87 | leg4_points,
88 | leg5_points,
89 | ]
90 |
--------------------------------------------------------------------------------
/widgets/ik_ui.py:
--------------------------------------------------------------------------------
1 | # Widgets used to set the inverse kinematics parameters
2 | import dash_core_components as dcc
3 | import dash_html_components as html
4 | from dash.dependencies import Input
5 | import dash_daq
6 | from texts import IK_WIDGETS_HEADER
7 | from style_settings import (
8 | SLIDER_THEME,
9 | SLIDER_HANDLE_COLOR,
10 | SLIDER_COLOR,
11 | IK_SLIDER_SIZE,
12 | )
13 | from settings import (
14 | UPDATE_MODE,
15 | BODY_MAX_ANGLE,
16 | HIP_STANCE_MAX_ANGLE,
17 | LEG_STANCE_MAX_ANGLE,
18 | SLIDER_ANGLE_RESOLUTION,
19 | )
20 |
21 |
22 | def make_row(divs):
23 | widget_style = {"padding": "1.0em 0 0 4.0em"}
24 | row_style = {"display": "flex", "flex-direction": "row"}
25 | widgets = [html.Div(div, style=widget_style) for div in divs]
26 | return html.Div(widgets, style=row_style)
27 |
28 |
29 | def make_translate_slider(name, slider_label):
30 | handle_style = {
31 | "showCurrentValue": True,
32 | "color": SLIDER_HANDLE_COLOR,
33 | "label": slider_label,
34 | }
35 |
36 | return dash_daq.Slider( # pylint: disable=not-callable
37 | id=name,
38 | min=-1.0,
39 | max=1.0,
40 | value=0.05,
41 | step=0.05,
42 | vertical=True,
43 | size=IK_SLIDER_SIZE,
44 | updatemode=UPDATE_MODE,
45 | handleLabel=handle_style,
46 | color={"default": SLIDER_COLOR},
47 | theme=SLIDER_THEME,
48 | )
49 |
50 |
51 | def make_rotate_slider(name, slider_label, max_angle=BODY_MAX_ANGLE):
52 | handle_style = {
53 | "showCurrentValue": True,
54 | "color": SLIDER_HANDLE_COLOR,
55 | "label": slider_label,
56 | }
57 | return dash_daq.Slider( # pylint: disable=not-callable
58 | id=name,
59 | min=-max_angle,
60 | max=max_angle,
61 | value=1.5,
62 | step=SLIDER_ANGLE_RESOLUTION,
63 | vertical=True,
64 | size=IK_SLIDER_SIZE,
65 | updatemode=UPDATE_MODE,
66 | handleLabel=handle_style,
67 | color={"default": SLIDER_COLOR},
68 | theme=SLIDER_THEME,
69 | )
70 |
71 |
72 | # ................................
73 | # COMPONENTS
74 | # ................................
75 |
76 | HEADER = html.Label(dcc.Markdown(f"**{IK_WIDGETS_HEADER}**"))
77 | IK_WIDGETS_IDS = [
78 | "widget-start-hip-stance",
79 | "widget-start-leg-stance",
80 | "widget-percent-x",
81 | "widget-percent-y",
82 | "widget-percent-z",
83 | "widget-rot-x",
84 | "widget-rot-y",
85 | "widget-rot-z",
86 | ]
87 | IK_CALLBACK_INPUTS = [Input(input_id, "value") for input_id in IK_WIDGETS_IDS]
88 |
89 | w_hips = make_rotate_slider(
90 | IK_WIDGETS_IDS[0], "start\nhip.stance", HIP_STANCE_MAX_ANGLE
91 | )
92 | w_legs = make_rotate_slider(
93 | IK_WIDGETS_IDS[1], "start\nleg.stance", LEG_STANCE_MAX_ANGLE
94 | )
95 |
96 | w_tx = make_translate_slider(IK_WIDGETS_IDS[2], "percent.x")
97 | w_ty = make_translate_slider(IK_WIDGETS_IDS[3], "percent.y")
98 | w_tz = make_translate_slider(IK_WIDGETS_IDS[4], "percent.z")
99 |
100 | w_rx = make_rotate_slider(IK_WIDGETS_IDS[5], "rot.x")
101 | w_ry = make_rotate_slider(IK_WIDGETS_IDS[6], "rot.y")
102 | w_rz = make_rotate_slider(IK_WIDGETS_IDS[7], "rot.z")
103 |
104 | row1 = make_row([w_hips, w_tx, w_ty, w_tz])
105 | row2 = make_row([w_legs, w_rx, w_ry, w_rz])
106 |
107 | IK_WIDGETS_SECTION = html.Div([HEADER, row1, row2])
108 |
--------------------------------------------------------------------------------
/tests/kinematics_cases/case1.py:
--------------------------------------------------------------------------------
1 | from hexapod.points import Vector
2 |
3 | description = "Kinematics Random Pose #1"
4 |
5 | # ********************************
6 | # Dimensions
7 | # ********************************
8 |
9 | given_dimensions = {
10 | "front": 75,
11 | "side": 100,
12 | "middle": 125,
13 | "coxia": 50,
14 | "femur": 130,
15 | "tibia": 200,
16 | }
17 |
18 | # ********************************
19 | # Poses
20 | # ********************************
21 |
22 | given_poses = {
23 | 0: {"coxia": -40, "femur": 19, "tibia": 6, "name": "right-middle", "id": 0},
24 | 1: {"coxia": 33, "femur": 85, "tibia": -60, "name": "right-front", "id": 1},
25 | 2: {"coxia": -20, "femur": 90, "tibia": -13, "name": "left-front", "id": 2},
26 | 3: {"coxia": -12, "femur": -25, "tibia": 3, "name": "left-middle", "id": 3},
27 | 4: {"coxia": 0, "femur": 94, "tibia": -70, "name": "left-back", "id": 4},
28 | 5: {"coxia": -5, "femur": 17, "tibia": 2, "name": "right-back", "id": 5},
29 | }
30 |
31 |
32 | # ********************************
33 | # Correct Body Vectors
34 | # ********************************
35 |
36 | correct_body_points = [
37 | Vector(x=+97.74, y=+69.20, z=+123.97, name="right-middle"),
38 | Vector(x=-3.68, y=+111.78, z=+103.96, name="right-front"),
39 | Vector(x=-120.97, y=+28.74, z=+146.94, name="left-front"),
40 | Vector(x=-97.74, y=-69.20, z=+195.60, name="left-middle"),
41 | Vector(x=+3.68, y=-111.78, z=+215.60, name="left-back"),
42 | Vector(x=+120.97, y=-28.74, z=+172.63, name="right-back"),
43 | Vector(x=+0.00, y=+0.00, z=+159.78, name="center-of-gravity"),
44 | Vector(x=-62.33, y=+70.26, z=+125.45, name="head"),
45 | ]
46 |
47 | # ********************************
48 | # Leg Vectors
49 | # ********************************
50 |
51 | leg0_points = [
52 | Vector(x=+97.74, y=+69.20, z=+123.97, name="right-middle-body-contact"),
53 | Vector(x=+147.72, y=+67.82, z=+124.03, name="right-middle-coxia"),
54 | Vector(x=+271.07, y=+83.36, z=+162.03, name="right-middle-femur"),
55 | Vector(x=+353.52, y=-0.00, z=+0.00, name="right-middle-tibia"),
56 | ]
57 |
58 | leg1_points = [
59 | Vector(x=-3.68, y=+111.78, z=+103.96, name="right-front-body-contact"),
60 | Vector(x=-26.03, y=+151.90, z=+84.19, name="right-front-coxia"),
61 | Vector(x=-29.64, y=+218.89, z=+195.55, name="right-front-femur"),
62 | Vector(x=-69.47, y=+205.68, z=+0.00, name="right-front-tibia"),
63 | ]
64 |
65 | leg2_points = [
66 | Vector(x=-120.97, y=+28.74, z=+146.94, name="left-front-body-contact"),
67 | Vector(x=-165.74, y=+48.88, z=+137.44, name="left-front-coxia"),
68 | Vector(x=-164.27, y=+107.00, z=+253.72, name="left-front-femur"),
69 | Vector(x=-339.26, y=+165.39, z=+176.44, name="left-front-tibia"),
70 | ]
71 |
72 | leg3_points = [
73 | Vector(x=-97.74, y=-69.20, z=+195.60, name="left-middle-body-contact"),
74 | Vector(x=-142.46, y=-88.97, z=+206.04, name="left-middle-coxia"),
75 | Vector(x=-248.46, y=-160.12, z=+181.51, name="left-middle-femur"),
76 | Vector(x=-183.54, y=-213.39, z=+0.00, name="left-middle-tibia"),
77 | ]
78 |
79 | leg4_points = [
80 | Vector(x=+3.68, y=-111.78, z=+215.60, name="left-back-body-contact"),
81 | Vector(x=-1.93, y=-156.20, z=+237.87, name="left-back-coxia"),
82 | Vector(x=+0.55, y=-90.17, z=+349.83, name="left-back-femur"),
83 | Vector(x=-10.63, y=-244.11, z=+222.63, name="left-back-tibia"),
84 | ]
85 |
86 | leg5_points = [
87 | Vector(x=+120.97, y=-28.74, z=+172.63, name="right-back-body-contact"),
88 | Vector(x=+169.97, y=-37.86, z=+176.57, name="right-back-coxia"),
89 | Vector(x=+292.24, y=-43.54, z=+220.36, name="right-back-femur"),
90 | Vector(x=+353.93, y=-139.96, z=+56.35, name="right-back-tibia"),
91 | ]
92 |
93 | correct_leg_points = [
94 | leg0_points,
95 | leg1_points,
96 | leg2_points,
97 | leg3_points,
98 | leg4_points,
99 | leg5_points,
100 | ]
101 |
--------------------------------------------------------------------------------
/hexapod/ground_contact_solver/ground_contact_solver2.py:
--------------------------------------------------------------------------------
1 | """
2 | ❗❗❗This is a more general algorithm to account for the cases
3 | that are not handled correctly by the other ground_contact_solver.
4 | This will only be used by the kinematics-page of the app.
5 | This algorithm can be optimized or replaced if a more elegant
6 | algorithm is available.
7 | ❗❗❗
8 |
9 | We have 18 points total.
10 | (6 legs, three possible points per leg)
11 |
12 | We have a total of 540 combinations
13 | - get three legs out of six (20 combinations)
14 | - we have three possible points for each leg, that's 27 (3^3)
15 | - 27 * 20 is 540
16 |
17 | For each combination:
18 | 1. Check if stable if not, next
19 | - Check if the projection of the center of gravity to the plane
20 | defined by the three points lies inside the triangle, if not, next
21 | 2. Get the height and normal of the height and normal of the triangle plane
22 | (We need this for the next part)
23 | 3. For each of the three leg, check if the two other points on the leg is not a
24 | lower height, if condition if broken, next. (6 points total)
25 | 4. For each of the three other legs, check all points (3 points of each leg)
26 | if so, next. (9 points total)
27 | 5. If no condition is violated, then this is good, return this!
28 | """
29 | import random
30 | from hexapod.ground_contact_solver.shared import (
31 | is_stable,
32 | is_lower,
33 | find_legs_on_ground,
34 | SOME_LEG_TRIOS,
35 | ADJACENT_LEG_TRIOS,
36 | )
37 | from hexapod.points import get_normal_given_three_points, dot
38 |
39 | OTHER_POINTS_MAP = {1: (2, 3), 2: (3, 1), 3: (1, 2)}
40 |
41 | JOINT_TRIOS = []
42 | for i in range(3, 0, -1):
43 | for j in range(3, 0, -1):
44 | for k in range(3, 0, -1):
45 | JOINT_TRIOS.append((i, j, k))
46 |
47 |
48 | def compute_orientation_properties(legs):
49 | """
50 | Returns:
51 | - Which legs are on the ground
52 | - Normal vector of the plane defined by these legs
53 | - Distance of this plane to center of gravity
54 | """
55 | # prefer leg combinations where legs are not adjacent to each other
56 | # introduce some randomness so we are not bias in
57 | # choosing one stable position over another
58 | shuffled_some_leg_trios = random.sample(SOME_LEG_TRIOS, len(SOME_LEG_TRIOS))
59 | leg_trios = shuffled_some_leg_trios + ADJACENT_LEG_TRIOS
60 |
61 | for leg_trio in leg_trios:
62 |
63 | other_leg_trio = [i for i in range(6) if i not in leg_trio]
64 | other_three_legs = [legs[i] for i in other_leg_trio]
65 | three_legs = [legs[i] for i in leg_trio]
66 |
67 | for joint_trio in JOINT_TRIOS:
68 |
69 | p0, p1, p2 = [legs[i].get_point(j) for i, j in zip(leg_trio, joint_trio)]
70 |
71 | if not is_stable(p0, p1, p2):
72 | continue
73 |
74 | n = get_normal_given_three_points(p0, p1, p2)
75 | height = -dot(n, p0)
76 |
77 | if same_leg_joints_break_condition(three_legs, joint_trio, n, height):
78 | continue
79 |
80 | if other_leg_joints_break_condition(other_three_legs, n, height):
81 | continue
82 |
83 | legs_on_ground = find_legs_on_ground(legs, n, height)
84 | return legs_on_ground, n, height
85 |
86 | return [], None, None
87 |
88 |
89 | def same_leg_joints_break_condition(three_legs, three_point_ids, n, height):
90 | for leg, point_id in zip(three_legs, three_point_ids):
91 | for other_point_id in OTHER_POINTS_MAP[point_id]:
92 | point = leg.get_point(other_point_id)
93 | if is_lower(point, height, n):
94 | return True
95 | return False
96 |
97 |
98 | def other_leg_joints_break_condition(other_three_legs, n, height):
99 | for leg in other_three_legs:
100 | for point in leg.all_points[1:]:
101 | if is_lower(point, height, n):
102 | return True
103 | return False
104 |
--------------------------------------------------------------------------------
/hexapod/plotter.py:
--------------------------------------------------------------------------------
1 | # ********************
2 | # This module updates the figure dictionary
3 | # that plotly uses to draw the 3d graph
4 | # it takes in a hexapod model, and the figure to update
5 | # you can also update the camera view with it by passing a camera dictionary
6 | # ********************
7 |
8 |
9 | class HexapodPlotter:
10 | def __init__(self):
11 | pass
12 |
13 | @staticmethod
14 | def update(fig, hexapod):
15 | HexapodPlotter._draw_hexapod(fig, hexapod)
16 | HexapodPlotter._draw_scene(fig, hexapod)
17 |
18 | @staticmethod
19 | def change_camera_view(fig, camera):
20 | # camera = { 'up': {'x': 0, 'y': 0, 'z': 0},
21 | # 'center': {'x': 0, 'y': 0, 'z': 0},
22 | # 'eye': {'x': 0, 'y': 0, 'z': 0)}}
23 | fig["layout"]["scene"]["camera"] = camera
24 |
25 | @staticmethod
26 | def _draw_hexapod(fig, hexapod):
27 | # Body
28 | points = hexapod.body.vertices + [hexapod.body.vertices[0]]
29 |
30 | # Body Surface Mesh
31 | fig["data"][0]["x"] = [point.x for point in points]
32 | fig["data"][0]["y"] = [point.y for point in points]
33 | fig["data"][0]["z"] = [point.z for point in points]
34 |
35 | # Body Outline
36 | fig["data"][1]["x"] = fig["data"][0]["x"]
37 | fig["data"][1]["y"] = fig["data"][0]["y"]
38 | fig["data"][1]["z"] = fig["data"][0]["z"]
39 |
40 | fig["data"][2]["x"] = [hexapod.body.cog.x]
41 | fig["data"][2]["y"] = [hexapod.body.cog.y]
42 | fig["data"][2]["z"] = [hexapod.body.cog.z]
43 |
44 | fig["data"][3]["x"] = [hexapod.body.head.x]
45 | fig["data"][3]["y"] = [hexapod.body.head.y]
46 | fig["data"][3]["z"] = [hexapod.body.head.z]
47 |
48 | for n, leg in zip(range(4, 10), hexapod.legs):
49 | points = leg.all_points
50 | fig["data"][n]["x"] = [point.x for point in points]
51 | fig["data"][n]["y"] = [point.y for point in points]
52 | fig["data"][n]["z"] = [point.z for point in points]
53 |
54 | # Hexapod Support Polygon
55 | # Draw a mesh for body contact on ground
56 | dz = -1 # Mesh must be slightly below ground
57 | ground_contacts = hexapod.ground_contacts
58 | fig["data"][10]["x"] = [point.x for point in ground_contacts]
59 | fig["data"][10]["y"] = [point.y for point in ground_contacts]
60 | fig["data"][10]["z"] = [(point.z + dz) for point in ground_contacts]
61 |
62 | @staticmethod
63 | def _draw_scene(fig, hexapod):
64 | # Change range of view for all axes
65 | RANGE = hexapod.sum_of_dimensions()
66 | AXIS_RANGE = [-RANGE, RANGE]
67 |
68 | z_start = -10
69 | fig["layout"]["scene"]["xaxis"]["range"] = AXIS_RANGE
70 | fig["layout"]["scene"]["yaxis"]["range"] = AXIS_RANGE
71 | fig["layout"]["scene"]["zaxis"]["range"] = [z_start, (RANGE - z_start) * 2]
72 |
73 | axis_scale = hexapod.front / 2
74 |
75 | # Draw the hexapod local frame
76 | cog = hexapod.body.cog
77 | x_axis = hexapod.x_axis
78 | y_axis = hexapod.y_axis
79 | z_axis = hexapod.z_axis
80 |
81 | fig["data"][11]["x"] = [cog.x, cog.x + axis_scale * x_axis.x]
82 | fig["data"][11]["y"] = [cog.y, cog.y + axis_scale * x_axis.y]
83 | fig["data"][11]["z"] = [cog.z, cog.z + axis_scale * x_axis.z]
84 |
85 | fig["data"][12]["x"] = [cog.x, cog.x + axis_scale * y_axis.x]
86 | fig["data"][12]["y"] = [cog.y, cog.y + axis_scale * y_axis.y]
87 | fig["data"][12]["z"] = [cog.z, cog.z + axis_scale * y_axis.z]
88 |
89 | fig["data"][13]["x"] = [cog.x, cog.x + axis_scale * z_axis.x]
90 | fig["data"][13]["y"] = [cog.y, cog.y + axis_scale * z_axis.y]
91 | fig["data"][13]["z"] = [cog.z, cog.z + axis_scale * z_axis.z]
92 |
93 | # Scale the global coordinate frame
94 | fig["data"][14]["x"] = [0, axis_scale]
95 | fig["data"][14]["y"] = [0, 0]
96 | fig["data"][14]["z"] = [0, 0]
97 |
98 | fig["data"][15]["x"] = [0, 0]
99 | fig["data"][15]["y"] = [0, axis_scale]
100 | fig["data"][15]["z"] = [0, 0]
101 |
102 | fig["data"][16]["x"] = [0, 0]
103 | fig["data"][16]["y"] = [0, 0]
104 | fig["data"][16]["z"] = [0, axis_scale]
105 |
--------------------------------------------------------------------------------
/hexapod/ground_contact_solver/ground_contact_solver.py:
--------------------------------------------------------------------------------
1 | """
2 | ❗❗❗An algorithm for computing the robot's orientation
3 | assuming probably ground contacts are known.
4 |
5 | This algorithm rests upon the assumption that it
6 | knows which point of the each leg is in contact with the ground.
7 | This assumption seems to be true for all possible cases for
8 | leg-patterns page and inverse-kinematics page.
9 |
10 | But this is not true for all possible
11 | angle combinations (18 angles) that can be defined in
12 | the kinematics page.
13 |
14 | This module is used for the leg-patterns page,
15 | and the inverse-kinematics page.
16 |
17 | The other module will be used for the kinematics page.
18 | ❗❗❗
19 |
20 | This module is responsible for the following:
21 | 1. determining which legs of the hexapod is on the ground
22 | 2. Computing the normal vector of the triangle defined by at least three legs on the ground
23 | the normal vector wrt to the old normal vector that is defined by the legs on the ground in
24 | the hexapod's neutral position
25 | 3. Determining the height of the center of gravity (cog) wrt to the ground.
26 | ie this height is distance between the cog and the plane defined by ground contacts.
27 | """
28 | from hexapod.points import dot, get_normal_given_three_points
29 | from hexapod.ground_contact_solver.shared import (
30 | is_stable,
31 | is_lower,
32 | find_legs_on_ground,
33 | LEG_TRIOS,
34 | )
35 |
36 |
37 | def compute_orientation_properties(legs):
38 | """
39 | Returns:
40 | - Which legs are on the ground
41 | - Normal vector of the plane defined by these legs
42 | - Distance of this plane to center of gravity
43 | """
44 | n, height = find_ground_plane_properties(legs)
45 |
46 | # this pose is unstable, The hexapod has no balance
47 | if n is None:
48 | return [], None, None
49 |
50 | return find_legs_on_ground(legs, n, height), n, height
51 |
52 |
53 | def find_ground_plane_properties(legs):
54 | """
55 | Return three legs forming a stable position from legs,
56 | or None if no three legs satisfy this requirement.
57 | It also returns the normal vector of the plane
58 | defined by the three ground contacts, and the
59 | computed distance of the hexapod body to the ground plane
60 | """
61 | ground_contacts = [leg.ground_contact() for leg in legs]
62 |
63 | # (2, 3, 5) is a trio from the set [0, 1, 2, 3, 4, 5]
64 | # the corresponding other_trio of (2, 3, 5) is (0, 1, 4)
65 | # order is not important ie (2, 3, 5) is the same as (5, 3, 2)
66 | for trio in LEG_TRIOS:
67 | p0, p1, p2 = [ground_contacts[i] for i in trio]
68 |
69 | if not is_stable(p0, p1, p2):
70 | continue
71 |
72 | # Get the vector normal to plane defined by these points
73 | # ❗IMPORTANT: The normal is always pointing up
74 | # because of how we specified the order of the trio
75 | # (and the legs in general)
76 | # starting from middle-right (id:0) to right back (id:5)
77 | # always towards one direction (ccw)
78 | n = get_normal_given_three_points(p0, p1, p2)
79 |
80 | # p0 is vector from cog (0, 0, 0) to ground contact
81 | # dot product of this and normal we get the
82 | # hypothetical (negative) height of ground contact to cog
83 | #
84 | # cog * ^ (normal_vector) ----
85 | # \ | |
86 | # \ | -height
87 | # \| |
88 | # V p0 (foot_tip) ------
89 | #
90 | # using p0, p1 or p2 should yield the same result
91 | height = -dot(n, p0)
92 |
93 | # height should be the highest since
94 | # the plane defined by this trio is on the ground
95 | # the other legs ground contact cannot be lower than the ground
96 | other_trio = [i for i in range(6) if i not in trio]
97 | other_points = [ground_contacts[i] for i in other_trio]
98 | if no_other_legs_lower(n, height, other_points):
99 | # Found one!
100 | return n, height
101 |
102 | # Nothing met the condition
103 | return None, None
104 |
105 |
106 | def no_other_legs_lower(n, height, other_points):
107 | for point in other_points:
108 | if is_lower(point, height, n):
109 | return False
110 |
111 | return True
112 |
--------------------------------------------------------------------------------
/tests/kinematics_cases/case2.py:
--------------------------------------------------------------------------------
1 | from hexapod.points import Vector
2 |
3 | description = "Kinematics Random Pose #2"
4 |
5 | # ********************************
6 | # Dimensions
7 | # ********************************
8 |
9 | given_dimensions = {
10 | "front": 53,
11 | "side": 112,
12 | "middle": 124,
13 | "coxia": 65,
14 | "femur": 147,
15 | "tibia": 158,
16 | }
17 |
18 | # ********************************
19 | # Poses
20 | # ********************************
21 |
22 | given_poses = {
23 | 0: {
24 | "name": "right-middle",
25 | "id": 0,
26 | "coxia": -46.173564612682185,
27 | "femur": -0.5639873561713742,
28 | "tibia": -22.853557731606656,
29 | },
30 | 1: {
31 | "name": "right-front",
32 | "id": 1,
33 | "coxia": -38.57261437211969,
34 | "femur": -3.2736722565308938,
35 | "tibia": -24.640160005779748,
36 | },
37 | 2: {
38 | "name": "left-front",
39 | "id": 2,
40 | "coxia": 2.0526054688295687,
41 | "femur": 35.09407799312794,
42 | "tibia": -31.188148885325916,
43 | },
44 | 3: {
45 | "name": "left-middle",
46 | "id": 3,
47 | "coxia": -16.947073091191385,
48 | "femur": 46.44561383735447,
49 | "tibia": -19.412041056143877,
50 | },
51 | 4: {
52 | "name": "left-back",
53 | "id": 4,
54 | "coxia": -33.39847023693062,
55 | "femur": 41.05787103974741,
56 | "tibia": -5.900804146706449,
57 | },
58 | 5: {
59 | "name": "right-back",
60 | "id": 5,
61 | "coxia": -38.67228081907621,
62 | "femur": 18.790558327957655,
63 | "tibia": -30.220554892132796,
64 | },
65 | }
66 |
67 | # ********************************
68 | # Correct Body Vectors
69 | # ********************************
70 |
71 | correct_body_points = [
72 | Vector(x=+112.68, y=+45.33, z=+126.66, name="right-middle"),
73 | Vector(x=+5.57, y=+122.87, z=+116.68, name="right-front"),
74 | Vector(x=-90.76, y=+84.12, z=+95.32, name="left-front"),
75 | Vector(x=-112.68, y=-45.33, z=+76.69, name="left-middle"),
76 | Vector(x=-5.57, y=-122.87, z=+86.67, name="left-back"),
77 | Vector(x=+90.76, y=-84.12, z=+108.03, name="right-back"),
78 | Vector(x=+0.00, y=+0.00, z=+101.67, name="center-of-gravity"),
79 | Vector(x=-42.60, y=+103.49, z=+106.00, name="head"),
80 | ]
81 |
82 |
83 | # ********************************
84 | # Leg Vectors
85 | # ********************************
86 |
87 | leg0_points = [
88 | Vector(x=+112.68, y=+45.33, z=+126.66, name="right-middle-body-contact"),
89 | Vector(x=+171.42, y=+18.46, z=+133.92, name="right-middle-coxia"),
90 | Vector(x=+304.49, y=-42.16, z=+148.91, name="right-middle-femur"),
91 | Vector(x=+272.70, y=+0.00, z=-0.00, name="right-middle-tibia"),
92 | ]
93 |
94 |
95 | leg1_points = [
96 | Vector(x=+5.57, y=+122.87, z=+116.68, name="right-front-body-contact"),
97 | Vector(x=+61.49, y=+153.21, z=+129.97, name="right-front-coxia"),
98 | Vector(x=+189.21, y=+222.64, z=+151.78, name="right-front-femur"),
99 | Vector(x=+149.60, y=+203.72, z=-0.00, name="right-front-tibia"),
100 | ]
101 |
102 | leg2_points = [
103 | Vector(x=-90.76, y=+84.12, z=+95.32, name="left-front-body-contact"),
104 | Vector(x=-150.83, y=+107.65, z=+87.44, name="left-front-coxia"),
105 | Vector(x=-276.55, y=+141.74, z=+155.58, name="left-front-femur"),
106 | Vector(x=-259.37, y=+163.25, z=+0.00, name="left-front-tibia"),
107 | ]
108 |
109 | leg3_points = [
110 | Vector(x=-112.68, y=-45.33, z=+76.69, name="left-middle-body-contact"),
111 | Vector(x=-176.39, y=-50.56, z=+64.89, name="left-middle-coxia"),
112 | Vector(x=-293.99, y=-70.60, z=+150.78, name="left-middle-femur"),
113 | Vector(x=-340.16, y=-60.64, z=-0.00, name="left-middle-tibia"),
114 | ]
115 |
116 | leg4_points = [
117 | Vector(x=-5.57, y=-122.87, z=+86.67, name="left-back-body-contact"),
118 | Vector(x=-58.45, y=-158.23, z=+73.33, name="left-back-coxia"),
119 | Vector(x=-165.26, y=-229.31, z=+145.09, name="left-back-femur"),
120 | Vector(x=-217.06, y=-264.36, z=+0.00, name="left-back-tibia"),
121 | ]
122 |
123 | leg5_points = [
124 | Vector(x=+90.76, y=-84.12, z=+108.03, name="right-back-body-contact"),
125 | Vector(x=+121.84, y=-141.20, z=+106.97, name="right-back-coxia"),
126 | Vector(x=+180.23, y=-268.69, z=+151.07, name="right-back-femur"),
127 | Vector(x=+191.91, y=-223.89, z=+0.00, name="right-back-tibia"),
128 | ]
129 |
130 |
131 | correct_leg_points = [
132 | leg0_points,
133 | leg1_points,
134 | leg2_points,
135 | leg3_points,
136 | leg4_points,
137 | leg5_points,
138 | ]
139 |
--------------------------------------------------------------------------------
/hexapod/ik_solver/recompute_hexapod.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | import numpy as np
3 | from hexapod.models import VirtualHexapod, Hexagon
4 | from hexapod.points import (
5 | angle_between,
6 | is_counter_clockwise,
7 | Vector,
8 | rotz,
9 | vector_from_to,
10 | length,
11 | )
12 | from settings import ASSERTION_ENABLED, PRINT_IK
13 |
14 |
15 | def recompute_hexapod(dimensions, ik_parameters, poses):
16 |
17 | # make the hexapod with all angles = 0
18 | # update the hexapod so that we know which given points are in contact with the ground
19 | old_hexapod = VirtualHexapod(dimensions)
20 | old_hexapod.update_stance(ik_parameters["hip_stance"], ik_parameters["leg_stance"])
21 | old_contacts = deepcopy(old_hexapod.ground_contacts)
22 |
23 | # make a new hexapod with all angles = 0
24 | # and update given the poses/ angles we've computed
25 | new_hexapod = VirtualHexapod(dimensions)
26 | new_hexapod.update(poses)
27 | new_contacts = deepcopy(new_hexapod.ground_contacts)
28 |
29 | # get two points that are on the ground before and after
30 | # updating to the given poses
31 | id1, id2 = find_two_same_leg_ids(old_contacts, new_contacts)
32 |
33 | old_p1 = deepcopy(old_hexapod.legs[id1].ground_contact())
34 | old_p2 = deepcopy(old_hexapod.legs[id2].ground_contact())
35 | new_p1 = deepcopy(new_hexapod.legs[id1].ground_contact())
36 | new_p2 = deepcopy(new_hexapod.legs[id2].ground_contact())
37 |
38 | # we must translate and rotate the hexapod with the pose
39 | # so that the hexapod is stepping on the old predefined ground contact points
40 | old_vector = vector_from_to(old_p1, old_p2)
41 | new_vector = vector_from_to(new_p1, new_p2)
42 |
43 | _, twist_frame = find_twist_to_recompute_hexapod(new_vector, old_vector)
44 | new_hexapod.rotate_and_shift(twist_frame, 0)
45 |
46 | twisted_p2 = new_hexapod.legs[id2].foot_tip()
47 | translate_vector = vector_from_to(twisted_p2, old_p2)
48 | new_hexapod.move_xyz(translate_vector.x, translate_vector.y, 0)
49 |
50 | might_sanity_check_points(new_p1, new_p2, old_p1, old_p2, new_vector, old_vector)
51 |
52 | return new_hexapod
53 |
54 |
55 | def make_contact_dict(ground_contact_list):
56 | # map index in ground_contact_list
57 | contact_dict = {}
58 | for contact in ground_contact_list:
59 | left_or_right, front_mid_back, _ = contact.name.split("-")
60 | leg_placement = left_or_right + "-" + front_mid_back
61 | leg_id = Hexagon.VERTEX_NAMES.index(leg_placement)
62 | contact_dict[leg_id] = leg_placement
63 |
64 | return contact_dict
65 |
66 |
67 | def find_two_same_leg_ids(old_contacts, new_contacts):
68 | same_ids = []
69 | old_contact_dict = make_contact_dict(old_contacts)
70 | new_contact_dict = make_contact_dict(new_contacts)
71 |
72 | if PRINT_IK:
73 | print("In recomputing hexapod:")
74 | print("...old contacts:", old_contact_dict)
75 | print("...new_contacts: ", old_contact_dict)
76 |
77 | for leg_id in old_contact_dict:
78 | if leg_id not in new_contact_dict:
79 | continue
80 |
81 | same_ids.append(leg_id)
82 | if len(same_ids) == 2:
83 | return same_ids[0], same_ids[1]
84 |
85 | raise Exception(
86 | f"Need at least two same points on ground.\n\
87 | old: {old_contact_dict}\n new: {new_contact_dict}"
88 | )
89 |
90 |
91 | def find_twist_to_recompute_hexapod(a, b):
92 | twist = angle_between(a, b)
93 | z_axis = Vector(0, 0, -1)
94 | is_ccw = is_counter_clockwise(a, b, z_axis)
95 | if is_ccw:
96 | twist = -twist
97 |
98 | twist_frame = rotz(twist)
99 | return twist, twist_frame
100 |
101 |
102 | def should_be_on_ground_msg(point):
103 | return f"Vector should be on the ground:\n{point}, z != 0"
104 |
105 |
106 | def might_sanity_check_points(new_p1, new_p2, old_p1, old_p2, new_vector, old_vector):
107 | if not ASSERTION_ENABLED:
108 | return
109 |
110 | print("Sanity check on hexapod.ik_solver.recompute_hexapod")
111 | assert np.isclose(new_p1.z, 0, atol=1), should_be_on_ground_msg(new_p1)
112 | assert np.isclose(new_p2.z, 0, atol=1), should_be_on_ground_msg(new_p2)
113 | assert np.isclose(old_p1.z, 0, atol=1), should_be_on_ground_msg(old_p1)
114 | assert np.isclose(old_p2.z, 0, atol=1), should_be_on_ground_msg(old_p2)
115 |
116 | assert new_p1.name == old_p1.name, f"Should be the same name:\n{old_p1}\n{new_p1}"
117 | assert new_p2.name == old_p2.name, f"Should be the same name:\n{old_p2}\n{new_p2}"
118 | assert np.isclose(
119 | length(new_vector), length(old_vector), atol=1.0
120 | ), f"Should be same length.\nnew_vector:{new_vector}\n old_vector:{old_vector}"
121 |
--------------------------------------------------------------------------------
/hexapod/ik_solver/helpers.py:
--------------------------------------------------------------------------------
1 | # Used for checking edge cases
2 | # and also for printing final results
3 | import json
4 | import numpy as np
5 | from settings import (
6 | PRINT_IK_LOCAL_LEG,
7 | ASSERTION_ENABLED,
8 | PRINT_IK,
9 | BETA_MAX_ANGLE,
10 | GAMMA_MAX_ANGLE,
11 | )
12 | from hexapod.points import length, vector_from_to, angle_between
13 |
14 | COXIA_ON_GROUND_ALERT_MSG = "Impossible at given height.\ncoxia joint shoved on ground"
15 | BODY_ON_GROUND_ALERT_MSG = "Impossible at given height.\nbody contact shoved on ground"
16 |
17 |
18 | def cant_reach_alert_msg(leg_name, problem):
19 | msg = "Cannot reach target ground point.\n"
20 | if problem == "femur":
21 | msg += f"Femur length of {leg_name} leg is too long."
22 | if problem == "tibia":
23 | msg += f"Femur length of {leg_name} leg is too long."
24 | else:
25 | # blocking
26 | msg = f"The {leg_name} leg cannot reach it because the ground is blocking the path."
27 | return msg
28 |
29 |
30 | def body_contact_shoved_on_ground(hexapod):
31 | for i in range(hexapod.LEG_COUNT):
32 | body_contact = hexapod.body.vertices[i]
33 | foot_tip = hexapod.legs[i].foot_tip()
34 | if body_contact.z < foot_tip.z:
35 | return True
36 | return False
37 |
38 |
39 | def legs_too_short(legs):
40 | # True when
41 | # if three of her left legs are up or
42 | # if three of her right legs are up or
43 | # if four legs are up
44 | if len(legs) >= 4:
45 | return True, f"Unstable. Too many legs off the floor.\n{legs}"
46 |
47 | leg_positions = [leg.split("-")[0] for leg in legs]
48 | if leg_positions.count("left") == 3:
49 | return True, f"Unstable. All left legs off the ground.\n{legs}"
50 | if leg_positions.count("right") == 3:
51 | return True, f"Unstable. All right legs off the ground.\n{legs}"
52 |
53 | return False, None
54 |
55 |
56 | def angle_above_limit(angle, angle_range, leg_name, angle_name):
57 | if np.abs(angle) > angle_range:
58 | alert_msg = f"The {angle_name} (of {leg_name} leg) required\n\
59 | to do this pose is beyond the range of motion.\n\
60 | Required: {angle} degrees. Limit: {angle_range} degrees."
61 | return True, alert_msg
62 |
63 | return False, None
64 |
65 |
66 | def beta_gamma_not_in_range(beta, gamma, leg_name):
67 | limit, msg = angle_above_limit(beta, BETA_MAX_ANGLE, leg_name, "(beta/femur)")
68 | if limit:
69 | return True, msg
70 |
71 | limit, msg = angle_above_limit(gamma, GAMMA_MAX_ANGLE, leg_name, "(gamma/tibia)")
72 | if limit:
73 | return True, msg
74 |
75 | return False, None
76 |
77 |
78 | def wrong_length_msg(leg_name, limb_name, limb_value):
79 | return f"Wrong {limb_name} vector length. {leg_name} coxia:{limb_value}"
80 |
81 |
82 | def might_sanity_leg_lengths_check(hexapod, leg_name, points):
83 | if not ASSERTION_ENABLED:
84 | return
85 |
86 | coxia = length(vector_from_to(points[0], points[1]))
87 | femur = length(vector_from_to(points[1], points[2]))
88 | tibia = length(vector_from_to(points[2], points[3]))
89 |
90 | same_length = np.isclose(hexapod.coxia, coxia, atol=1)
91 | assert same_length, wrong_length_msg(leg_name, "coxia", coxia)
92 |
93 | same_length = np.isclose(hexapod.femur, femur, atol=1)
94 | assert same_length, wrong_length_msg(leg_name, "femur", femur)
95 |
96 | same_length = np.isclose(hexapod.tibia, tibia, atol=1)
97 | assert same_length, wrong_length_msg(leg_name, "tibia", tibia)
98 |
99 |
100 | def might_sanity_beta_gamma_check(beta, gamma, leg_name, points):
101 | if not ASSERTION_ENABLED:
102 | return
103 |
104 | coxia = vector_from_to(points[0], points[1])
105 | femur = vector_from_to(points[1], points[2])
106 | tibia = vector_from_to(points[2], points[3])
107 | result_beta = angle_between(coxia, femur)
108 |
109 | same_beta = np.isclose(np.abs(beta), result_beta, atol=1)
110 | assert same_beta, f"{leg_name} leg: expected: |{beta}|, found: {result_beta}"
111 |
112 | # ❗IMPORTANT: Sometimes both are zero. Is this wrong?
113 | femur_tibia_angle = angle_between(femur, tibia)
114 | is_90 = np.isclose(90, femur_tibia_angle + gamma, atol=1)
115 |
116 | if not is_90:
117 | alert_msg = f"{leg_name} leg:\
118 | {femur_tibia_angle} (femur-tibia angle) + {gamma} (gamma) != 90."
119 | print(alert_msg)
120 |
121 |
122 | def might_print_ik(poses, ik_parameters, hexapod):
123 | if not PRINT_IK:
124 | return
125 |
126 | print("█████████████████████████████")
127 | print("█ START INVERSE KINEMATICS █")
128 | print("█████████████████████████████")
129 |
130 | print(".....................")
131 | print("... hexapod dimensions: ")
132 | print(".....................")
133 | print(json.dumps(hexapod.dimensions, indent=4))
134 |
135 | print(".....................")
136 | print("... ik parameters: ")
137 | print(".....................")
138 | print(json.dumps(ik_parameters, indent=4))
139 |
140 | print(".....................")
141 | print("... poses: ")
142 | print(".....................")
143 | print(json.dumps(poses, indent=4))
144 |
145 | print("█████████████████████████████")
146 | print("█ END INVERSE KINEMATICS █")
147 | print("█████████████████████████████")
148 |
149 |
150 | def might_print_points(points, leg_name):
151 | if not PRINT_IK_LOCAL_LEG:
152 | return
153 |
154 | print(leg_name, "leg")
155 | for i, point in enumerate(points):
156 | print(f"...p{i}: {point}")
157 | print()
158 |
--------------------------------------------------------------------------------
/hexapod/ik_solver/README.md:
--------------------------------------------------------------------------------
1 | # The Inverse Kinematics Algorithm
2 |
3 | ## Overview
4 |
5 | ```python
6 | # Given we know:
7 | # - The point where the hexapod body connect with each hexapod leg (point p0 aka body contact)
8 | # - Where the hexapod leg should connect with the ground (p3 aka target ground point)
9 | #
10 | # We want to solve for:
11 | # - All the 18 angles (alpha, beta, gamma)
12 | # - All the 12 joint points (coxia joint p1) and (femur joint p2)
13 |
14 | ```
15 |
16 | ## Rough algorithm in the hexapod frame
17 |
18 | In the hexapod frame, the coxia vector is the projected vector (in the plane of the hexapod body) of the vector from the body contact pointing to the target ground point.
19 |
20 | ```python
21 | # body_to_foot_vector = vector from p0 to p3
22 | # coxia vector = projection of the body_to_foot vector to the hexapod body (hexagon) plane
23 | #
24 | # * (p3) Target foot tip
25 | # *----*----* /
26 | # / \ /
27 | # / cog \ /
28 | # * * */ (p0) body contact
29 | # \ /
30 | # \ /
31 | # *----*----*
32 | #
33 | # hexapod y_axis
34 | # |
35 | # |
36 | # * - -> hexapod x_axis
37 | ```
38 |
39 | We can find the angle between the coxia vector wrt to the x axis of the hexapod.
40 | Then we can find the angle alpha which is the relative x axis for each attached linkage
41 | as shown in the figure which is the alpha.
42 |
43 | ```python
44 | # Relative x-axis, for each attached linkage
45 | # Angle each respective relative angle makes wrt to the
46 | # COXIA_AXES = [0, 45, 135, 180, 225, 315] starting from x0 to x5
47 | #
48 | # x2 x1
49 | # \ /
50 | # *---*---*
51 | # / | \
52 | # / | \
53 | # / | \
54 | # x3 --*------cog------*-- x0
55 | # \ | /
56 | # \ | /
57 | # \ | /
58 | # *---*---*
59 | # / \
60 | # x4 x5
61 | #
62 | # hexapod y
63 | # |
64 | # |
65 | # * - - hexapod x
66 | ```
67 |
68 | ## Rough algorithm in the local leg frame
69 |
70 | ```python
71 | # |--coxia-----\ ----
72 | # p0 ------- p1 \ \
73 | # \ femur
74 | # \ \
75 | # p2 ---
76 | # / /
77 | # / tibia
78 | # / /
79 | # p3 -------
80 | #
81 | # Given / Knowns:
82 | # - point p0
83 | # - point p3
84 | # - coxia length
85 | # - femur length
86 | # - tibia length
87 | # - given p0 and p1 lie on the same known axis
88 | #
89 | # Find /Unknowns:
90 | # p1
91 | # p2
92 | # beta - angle between coxia vector (leg x axis) and femur vector
93 | # gamma - angle between tibia vector and perpendicular vector to femur vector
94 | ```
95 |
96 | ## Definitions and Cases
97 |
98 | ```python
99 | #
100 | #
101 | # **********************
102 | # DEFINITIONS
103 | # **********************
104 | # p0: Body contact point
105 | # p1: coxia point / coxia joint (point between coxia limb and femur limb)
106 | # p2: tibia point / tibia joint (point between femur limb and tibia limb)
107 | # p3: foot tip / ground contact
108 | # coxia vector - vector from p0 to p1
109 | # hexapod.coxia - coxia vector length
110 | # femur vector - vector from p1 to p2
111 | # hexapod.femur - femur vector length
112 | # tibia vector - vector from p2 to p3
113 | # hexapod.tibia - tibia vector length
114 | # body_to_foot vector - vector from p0 to p3
115 | # coxia_to_foot vector - vector from p1 to p3
116 | # d: coxia_to_foot_length
117 | # body_to_foot_length
118 | #
119 | # rho
120 | # -- angle between coxia vector (leg x axis) and body to foot vector
121 | # -- angle between point p1, p0, and p3. (p0 at center)
122 | # theta
123 | # --- angle between femur vector and coxia to foot vector
124 | # --- angle between point p2, p1, and p3. (p1 at center)
125 | # phi
126 | # --- angle between coxia vector (leg x axis) and coxia to foot vector
127 | #
128 | # beta
129 | # --- angle between coxia vector (leg x axis) and femur vector
130 | # --- positive is counter clockwise
131 | # gamma
132 | # --- angle between tibia vector and perpendicular vector to femur vector
133 | # --- positive is counter clockwise
134 | # alpha
135 | # --- angle between leg coordinate frame and axis defined by line from
136 | # hexapod's center of gravity to body contact.
137 | #
138 | #
139 | # For CASE 1 and CASE 2:
140 | # beta = theta - phi
141 | # beta is positive when phi < theta (case 1)
142 | # beta is negative when phi > theta (case 2)
143 | # *****************
144 | # Case 1 (beta IS POSITIVE)
145 | # *****************
146 | #
147 | # ^ (p2)
148 | # | *
149 | # (leg_z_axis) / |
150 | # | / |
151 | # | / |
152 | # (p0) (p1)/ |
153 | # *------- *-----| ----------------> (leg_x_axis)
154 | # \ \ |
155 | # \ \ |
156 | # \ \ |
157 | # \ \ |
158 | # \ \ |
159 | # \ |
160 | # \ * (p3)
161 | #
162 | #
163 | # *****************
164 | # Case 2 (beta is negative)
165 | # *****************
166 | # ^
167 | # |
168 | # (leg_z_axis direction)
169 | # (p0) (p1) |
170 | # *------- *----------------|------> (leg_x_axis direction)
171 | # \ | \
172 | # \ | \
173 | # \ | \
174 | # \ | * (p2)
175 | # \ | /
176 | # \ | /
177 | # \ | /
178 | # \ | /
179 | # \| /
180 | # *
181 | #
182 | # *****************
183 | # Case 3 (p3 is above p1) then beta = phi + theta
184 | # *****************
185 | # * (p2)
186 | # / \
187 | # / |
188 | # / * (p3)
189 | # / /
190 | # *------ * /
191 | # (p0) (p1)
192 | #
193 | #
194 | ```
195 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://ko-fi.com/minimithi)
2 | [](https://travis-ci.com/github/mithi/hexapod-robot-simulator)
3 | [](https://codecov.io/gh/mithi/hexapod-robot-simulator)
4 | [](https://codeclimate.com/github/mithi/hexapod-robot-simulator)
5 | [](https://codeclimate.com/github/mithi/hexapod-robot-simulator/trends/technical_debt)
6 | [](https://hits.dwyl.com/mithi/hexapod-robot-simulator)
7 | [](./LICENSE)
8 | [](https://github.com/psf/black)
9 | [](https://github.com/mithi/hexapod-robot-simulator/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)
10 | [](https://www.firsttimersonly.com/)
11 |
12 | # Mithi's Hexapod Robot Simulator
13 |
14 | - A bare minimum browser-based hexapod robot simulator built from first principles 🕷️
15 | - If you like this project, consider [buying me a few ☕ cups of coffee](https://ko-fi.com/minimithi). 💕
16 |
17 | | | | | |
18 | |---------|---------|---------|---------|
19 | ||
|
||
20 |
21 | # Announcement
22 |
23 | You might be interested in checking out my [rewrite in Javascript](http://github.com/mithi/hexapod), live at: https://hexapod.netlify.app/ , which is like 10000000x faster. If you'd like to build you're own user interface with Node, you can download the algorithm alone as a package in the npm registry: [Hexapod Kinematics Library](https://github.com/mithi/hexapod-kinematics-library). There is also [a "fork" modified where you can use the app to control a physical hexapod robot](https://github.com/mithi/hexapod-irl) as you can see in the gif below.
24 |
25 |
26 |
27 |