diff --git a/.binder/apt.txt b/.binder/apt.txt
new file mode 100644
index 000000000..e5f2b6eb1
--- /dev/null
+++ b/.binder/apt.txt
@@ -0,0 +1,2 @@
+libgl1-mesa-dev
+xvfb
diff --git a/.binder/labconfig/default_setting_overrides.json b/.binder/labconfig/default_setting_overrides.json
new file mode 100644
index 000000000..84c367341
--- /dev/null
+++ b/.binder/labconfig/default_setting_overrides.json
@@ -0,0 +1,8 @@
+{
+  "@jupyterlab/docmanager-extension:plugin": {
+    "defaultViewers": {
+      "markdown": "Jupytext Notebook",
+      "myst": "Jupytext Notebook"
+    }
+  }
+}
diff --git a/.binder/postBuild b/.binder/postBuild
new file mode 100644
index 000000000..2514e5133
--- /dev/null
+++ b/.binder/postBuild
@@ -0,0 +1,6 @@
+# Stop everything if one command fails
+set -e
+
+# See https://github.com/mwouts/jupytext/issues/803#issuecomment-982170660
+mkdir -p ${HOME}/.jupyter/labconfig
+cp .binder/labconfig/* ${HOME}/.jupyter/labconfig
diff --git a/.binder/requirements.txt b/.binder/requirements.txt
new file mode 100644
index 000000000..e9704b8eb
--- /dev/null
+++ b/.binder/requirements.txt
@@ -0,0 +1 @@
+.[docs]
diff --git a/.binder/start b/.binder/start
new file mode 100644
index 000000000..ba526b95f
--- /dev/null
+++ b/.binder/start
@@ -0,0 +1,10 @@
+#!/bin/bash
+set -x
+export DISPLAY=:99.0
+export PYVISTA_OFF_SCREEN=true
+export PYVISTA_USE_IPYVTK=true
+which Xvfb
+Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
+sleep 3
+set +x
+exec "$@"
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index d24563ab5..000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-version: 2.1
-workflows:
-  build_and_deploy:
-    jobs:
-      - build:
-          filters:
-            tags:
-              only: /.*/
-      - deploy:
-          requires:
-            - build
-          filters:
-            tags:
-              only: /[0-9]+(?:\.[0-9]+)*(?:\-beta*|\-alpha*|\-rc)*(?:[0-9]+)*/
-            branches:
-              ignore: /.*/
-              
-jobs:
-  build:
-    docker:
-      - image: python:3
-    steps:
-      - checkout
-      - run: 
-          name: Install Testing Env
-          command: mkdir test-results && pip install tox
-      - run:
-          name: Run Test Suite
-          command: tox
-  deploy:
-    docker:
-      - image: python:3
-    steps:
-        - checkout
-        - run:
-            name: install python dependencies
-            command: |
-              python3 -m venv venv
-              . venv/bin/activate
-              make dev
-        - run:
-            name: verify git tag vs. version
-            command: |
-              python3 -m venv venv
-              . venv/bin/activate
-              python setup.py verify
-        - run:
-            name: init .pypirc
-            command: |
-              echo -e "[pypi]" >> ~/.pypirc
-              echo -e "username: magpylib" >> ~/.pypirc
-              echo -e "password: $PYPI_PASSWORD" >> ~/.pypirc
-        - run:
-            name: create packages
-            command: |
-              make package
-        - run:
-            name: upload to pypi
-            command: |
-              . venv/bin/activate
-              twine upload --repository pypi dist/*
diff --git a/.copier-answers.yml b/.copier-answers.yml
new file mode 100644
index 000000000..34056dc84
--- /dev/null
+++ b/.copier-answers.yml
@@ -0,0 +1,13 @@
+# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
+_commit: 2025.01.22-34-g133af09
+_src_path: gh:scientific-python/cookie
+backend: hatch
+email: magpylib@gmail.com
+full_name: Michael Ortner
+license: BSD
+org: magpylib
+project_name: magpylib
+project_short_description: Python package for computation of magnetic fields of magnets,
+    currents and moments.
+url: https://github.com/magpylib/magpylib
+vcs: true
diff --git a/.git_archival.txt b/.git_archival.txt
new file mode 100644
index 000000000..7c5100942
--- /dev/null
+++ b/.git_archival.txt
@@ -0,0 +1,3 @@
+node: $Format:%H$
+node-date: $Format:%cI$
+describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..00a7b00c9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+.git_archival.txt  export-subst
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 000000000..26e74cb00
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,89 @@
+See the [Scientific Python Developer Guide][spc-dev-intro] for a detailed
+description of best practices for developing scientific packages.
+
+[spc-dev-intro]: https://learn.scientific-python.org/development/
+
+# Quick development
+
+The fastest way to start with development is to use nox. If you don't have nox,
+you can use `pipx run nox` to run it without installing, or `pipx install nox`.
+If you don't have pipx (pip for applications), then you can install with
+`pip install pipx` (the only case were installing an application with regular
+pip is reasonable). If you use macOS, then pipx and nox are both in brew, use
+`brew install pipx nox`.
+
+To use, run `nox`. This will lint and test using every installed version of
+Python on your system, skipping ones that are not installed. You can also run
+specific jobs:
+
+```console
+$ nox -s lint  # Lint only
+$ nox -s tests  # Python tests
+$ nox -s docs  # Build and serve the docs
+$ nox -s build  # Make an SDist and wheel
+```
+
+Nox handles everything for you, including setting up an temporary virtual
+environment for each run.
+
+# Setting up a development environment manually
+
+You can set up a development environment by running:
+
+```bash
+python3 -m venv .venv
+source ./.venv/bin/activate
+pip install --group dev -e .
+```
+
+If you have the
+[Python Launcher for Unix](https://github.com/brettcannon/python-launcher), you
+can instead do:
+
+```bash
+py -m venv .venv
+py -m install --group dev -e .
+```
+
+# Pre-commit
+
+You should prepare pre-commit, which will help you by checking that commits pass
+required checks:
+
+```bash
+pip install pre-commit # or brew install pre-commit on macOS
+pre-commit install # Will install a pre-commit hook into the git repo
+```
+
+You can also/alternatively run `pre-commit run` (changes only) or
+`pre-commit run --all-files` to check even without installing the hook.
+
+# Testing
+
+Use pytest to run the unit checks:
+
+```bash
+pytest
+```
+
+# Coverage
+
+Use pytest-cov to generate coverage reports:
+
+```bash
+pytest --cov=magpylib
+```
+
+# Building docs
+
+You can build and serve the docs using:
+
+```bash
+nox -s docs
+```
+
+You can build the docs only with:
+
+```bash
+nox -s docs --non-interactive
+```
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 1ee4b4181..000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-name: Bug report
-about: Create a report to help improve magpylib!
-title: "[BUG] - "
-labels: ''
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-
-```python
-### Please provide either (or both):
-## - a step-by-step description of how to reproduce the issue
-## - a code excerpt, that can be run by itself, which reproduces the issue
-```
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Desktop (please complete the following information):**
- - OS: [e.g. Linux 32bit]
- - IDE [e.g. Spyder, PyCharm]
-
-**pip freeze output**
-Please run the `pip freeze` command in your python interpreter's environment and paste the output below.
-
-```
-pip freeze output:
-[ PASTE IT HERE ] 
-```
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index e97839ca0..000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Feature request
-about: Suggest something for magpylib!
-title: "[REQUEST]"
-labels: ''
-assignees: ''
-
----
-
-**Is your feature request related to some problem? If yes, please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the feature you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives or solutions you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 7bcadbae4..000000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# Related Issues
-
-# Notes
-
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..6c4b36953
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+  # Maintain dependencies for GitHub Actions
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+    groups:
+      actions:
+        patterns:
+          - "*"
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 000000000..9d1e0987b
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,5 @@
+changelog:
+  exclude:
+    authors:
+      - dependabot
+      - pre-commit-ci
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 000000000..1e70a5be6
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,60 @@
+name: CD
+
+on:
+  workflow_dispatch:
+  pull_request:
+  push:
+    branches:
+      - main
+  release:
+    types:
+      - published
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+env:
+  # Many color libraries just need this to be set to any value, but at least
+  # one distinguishes color depth, where "3" -> "256-bit color".
+  FORCE_COLOR: 3
+
+jobs:
+  dist:
+    name: Distribution build
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - uses: hynek/build-and-inspect-python-package@v2
+
+  publish:
+    needs: [dist]
+    name: Publish to PyPI
+    environment: pypi
+    permissions:
+      id-token: write
+      attestations: write
+      contents: read
+    runs-on: ubuntu-latest
+    if: github.event_name == 'release' && github.event.action == 'published'
+
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          name: Packages
+          path: dist
+
+      - name: Generate artifact attestation for sdist and wheel
+        uses: actions/attest-build-provenance@v2.3.0
+        with:
+          subject-path: "dist/*"
+
+      - uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          # Remember to tell (test-)pypi about this repo before publishing
+          # Remove this line to publish to PyPI
+          repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..544278837
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,73 @@
+name: CI
+
+on:
+  workflow_dispatch:
+  pull_request:
+  push:
+    branches:
+      - main
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+env:
+  FORCE_COLOR: 3
+
+jobs:
+  pre-commit:
+    name: Format
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: astral-sh/setup-uv@v6
+      - uses: pre-commit/action@v3.0.1
+        with:
+          extra_args: --hook-stage manual --all-files
+      - name: Run Pylint
+        run: uvx nox -s pylint -- --output-format=github
+
+  checks:
+    name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }}
+    runs-on: ${{ matrix.runs-on }}
+    needs: [pre-commit]
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.11", "3.12", "3.13"]
+        runs-on: [ubuntu-latest, windows-latest, macos-latest]
+
+        # include:
+        #   - python-version: "pypy-3.11"
+        #     runs-on: ubuntu-latest
+        # Note: Commented out because it fails tests for pyvista due to vtk wheels
+
+    steps:
+      - name: Setup headless display
+        uses: pyvista/setup-headless-display-action@v4
+
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Install OpenBLAS
+        if: runner.os == 'Linux' && startsWith(matrix.python-version, 'pypy')
+        run: sudo apt-get update && sudo apt-get install -y libopenblas-dev
+
+      - name: Install uv and set the python version
+        uses: astral-sh/setup-uv@v6
+        with:
+          python-version: ${{ matrix.python-version }}
+          activate-environment: true
+
+      - name: Test package
+        run: >-
+          uv run pytest -ra --cov --cov-report=xml --cov-report=term
+          --durations=20
+
+      - name: Upload coverage report
+        uses: codecov/codecov-action@v5
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 6e8a158f9..8d6fc047f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,3 @@
-
-# Created by https://www.gitignore.io/api/python
-# Edit at https://www.gitignore.io/?templates=python
-
-### Python ###
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
@@ -43,7 +38,6 @@ pip-delete-this-directory.txt
 
 # Unit test / coverage reports
 htmlcov/
-test-results/
 .tox/
 .nox/
 .coverage
@@ -52,8 +46,10 @@ test-results/
 nosetests.xml
 coverage.xml
 *.cover
+*.py,cover
 .hypothesis/
 .pytest_cache/
+cover/
 
 # Translations
 *.mo
@@ -63,6 +59,7 @@ coverage.xml
 *.log
 local_settings.py
 db.sqlite3
+db.sqlite3-journal
 
 # Flask stuff:
 instance/
@@ -74,7 +71,9 @@ instance/
 # Sphinx documentation
 docs/_build/
 docs/_autogen/
+
 # PyBuilder
+.pybuilder/
 target/
 
 # Jupyter Notebook
@@ -85,16 +84,28 @@ profile_default/
 ipython_config.py
 
 # pyenv
-.python-version
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
 
-# celery beat schedule file
+# Celery stuff
 celerybeat-schedule
+celerybeat.pid
 
 # SageMath parsed files
 *.sage.py
 
 # Environments
-.vscode/
 .env
 .venv
 env/
@@ -121,9 +132,32 @@ dmypy.json
 # Pyre type checker
 .pyre/
 
-### Python Patch ###
-.venv/
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# setuptools_scm
+src/*/_version.py
+
+
+# ruff
+.ruff_cache/
 
+# OS specific stuff
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
 
+# Common editor files
+*~
+*.swp
+__temp*.py
 
-# End of https://www.gitignore.io/api/python
+# uv
+uv.lock
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..bd678168d
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,91 @@
+ci:
+  autoupdate_commit_msg: "chore: update pre-commit hooks"
+  autofix_commit_msg: "style: pre-commit fixes"
+
+exclude: ^.cruft.json|.copier-answers.yml$
+
+repos:
+  - repo: https://github.com/adamchainz/blacken-docs
+    rev: "1.19.1"
+    hooks:
+      - id: blacken-docs
+        additional_dependencies: [black==24.*]
+
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: "v5.0.0"
+    hooks:
+      - id: check-added-large-files
+      - id: check-case-conflict
+      - id: check-merge-conflict
+      - id: check-symlinks
+      - id: check-yaml
+      - id: debug-statements
+      - id: end-of-file-fixer
+      - id: mixed-line-ending
+      - id: name-tests-test
+        args: ["--pytest-test-first"]
+      - id: requirements-txt-fixer
+      - id: trailing-whitespace
+
+  - repo: https://github.com/pre-commit/pygrep-hooks
+    rev: "v1.10.0"
+    hooks:
+      - id: rst-backticks
+      - id: rst-directive-colons
+      - id: rst-inline-touching-normal
+
+  - repo: https://github.com/rbubley/mirrors-prettier
+    rev: "v3.5.3"
+    hooks:
+      - id: prettier
+        types_or: [yaml, markdown, html, css, scss, javascript, json]
+        args: [--prose-wrap=always]
+        exclude: ^docs/ # messes up colon fences for grids in docs
+
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: "v0.11.10"
+    hooks:
+      - id: ruff
+        args: ["--fix", "--show-fixes"]
+      - id: ruff-format
+
+  # TODO: enforce type checking in new PR
+  #- repo: https://github.com/pre-commit/mirrors-mypy
+  #  rev: "v1.15.0"
+  #  hooks:
+  #    - id: mypy
+  #      files: src|tests
+  #      args: []
+  #      additional_dependencies:
+  #        - pytest
+
+  - repo: https://github.com/codespell-project/codespell
+    rev: "v2.4.1"
+    hooks:
+      - id: codespell
+
+  - repo: https://github.com/shellcheck-py/shellcheck-py
+    rev: "v0.10.0.1"
+    hooks:
+      - id: shellcheck
+
+  - repo: local
+    hooks:
+      - id: disallow-caps
+        name: Disallow improper capitalization
+        language: pygrep
+        entry: PyBind|Numpy|Cmake|CCache|Github|PyTest
+        exclude: .pre-commit-config.yaml
+
+  - repo: https://github.com/abravalheri/validate-pyproject
+    rev: "v0.24.1"
+    hooks:
+      - id: validate-pyproject
+        additional_dependencies: ["validate-pyproject-schema-store[all]"]
+
+  - repo: https://github.com/python-jsonschema/check-jsonschema
+    rev: "0.33.0"
+    hooks:
+      - id: check-dependabot
+      - id: check-github-workflows
+      - id: check-readthedocs
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..377c778f2
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,20 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+version: 2
+
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.12"
+  apt_packages:
+    # for pyvista
+    - libgl1-mesa-dev
+  commands:
+    - asdf plugin add uv
+    - asdf install uv latest
+    - asdf global uv latest
+    - uv venv
+    - uv sync --no-dev --no-default-groups --group docs
+    - .venv/bin/python -m sphinx -T -b html -d docs/_build/doctrees -D
+      language=en docs $READTHEDOCS_OUTPUT/html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2732ce07..ee98a5d3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,123 +1,793 @@
-# magpylib Changelog
+# Changelog
+
+## [Unreleased] - YYYY-MM-DD
+
+- Improved docstrings by adding examples where missing and by using rounding in
+  all examples to avoid doctest fails
+- Fixed a bug where a core getH would return the B-field
+- Input `in_out` is by default set to `"auto"` for collections to avoiding
+  ambiguities.
+- Improve documentation
+  ([#829](https://github.com/magpylib/magpylib/issues/829))
+
+## [5.1.1] - 2024-10-31
+
+- Included magpylib-force version 0.3.1 in documentation
+- Removed unused issue templates and improved PR template
+
+## [5.1.0] - 2024-10-09
+
+- Fixed a bug where the magnetization arrow graphical representation would be
+  anchored badly after rotation
+  ([#805](https://github.com/magpylib/magpylib/issues/805))
+- Added `units_length` input to the `show` function to allow displaying axes
+  with different length units. This parameter can be set individually for each
+  subplot. ([#786](https://github.com/magpylib/magpylib/pull/786))
+- Small documentation and Readme improvement. Change naming from "explicit
+  expression" to "analytical expression" as described in
+  ([#794](https://github.com/magpylib/magpylib/issues/794)).
+- Fixed Pvyvista plot bounds not fitting on animation. Also enables `zoom`
+  feature which was not working until now.
+  ([#798](https://github.com/magpylib/magpylib/pull/798))
+- Fixed canvas layout being modified even when user-provided. Also added a new
+  `canvas_update` parameter to choose the layout behavior (by default `"auto"`)
+  ([#799](https://github.com/magpylib/magpylib/pull/799))
+- Improved documentation
+  ([#766](https://github.com/magpylib/magpylib/issues/766),
+  [#802](https://github.com/magpylib/magpylib/issues/802))
+- Documentation now includes force computation, which is still in a separate
+  package "magpylib-force", but which will be integrated into Magplyib in the
+  coming months. ([#787](https://github.com/magpylib/magpylib/issues/787))
+
+## [5.0.4] - 2024-06-18
+
+- Added support for NumPy 2.0
+  ([#795](https://github.com/magpylib/magpylib/pull/789))
+- Fixed markers legend not being suppressible
+  ([#795](https://github.com/magpylib/magpylib/pull/789))
+
+## [5.0.3] - 2024-06-03
+
+- Fixed subplot object properties propagation
+  ([#780](https://github.com/magpylib/magpylib/pull/780))
+- Migrate to pydata-sphinx-theme and fix docs search function
+  ([#762](https://github.com/magpylib/magpylib/pull/762))
+- Fixed docs version-switcher
+  ([#782](https://github.com/magpylib/magpylib/pull/782))
+
+## [5.0.2] - 2024-05-21
+
+- Fixed a display issue causing incorrect calculation of view box limits
+  ([#772](https://github.com/magpylib/magpylib/pull/772))
+- Removed support for python 3.8 and 3.9 by now following the
+  scientific-python.org support timelines
+  ([#773](https://github.com/magpylib/magpylib/pull/773))
+- Fixed CI testing with newer backend versions
+  ([#774](https://github.com/magpylib/magpylib/pull/774))
+- Updated site notice to mention the awarded "small development grant" by
+  NumFocus. ([#758](https://github.com/magpylib/magpylib/pull/758))
+- Fixed inaccurate citation year for Yang publication
+  ([#764](https://github.com/magpylib/magpylib/pull/764), with thanks to
+  @feldnerd for the contribution!)
+
+## [5.0.1] - 2024-04-12
+
+- Fixed a bug where `getBHJM` of a Collection would produce one extra dimension
+  ([#753](https://github.com/magpylib/magpylib/issues/753))
+- Fixed a bug where the legend of a deeply nested Collection would be wrong
+  ([#756](https://github.com/magpylib/magpylib/issues/756))
+
+## [5.0.0] - 2024-03-13
+
+### ⚠️ Breaking Changes ⚠️
+
+- The Magpylib inputs and outputs are now in **SI Units**.
+- The `magnetization` parameter has also been redefined to reflect the true
+  physical magnetization quantity in units of A/m.
+
+### Other Improvements
+
+- The `magnetization` parameter is now codependent with the new `polarization`
+  parameter that is the physical magnetic polarization
+  ([#712](https://github.com/magpylib/magpylib/issues/712)) in units of Tesla
+- Added `getM` (magnetization) and `getJ` (polarization) top level functions and
+  class methods reminiscent of `getB` and `getH`.
+- The `in_out` (inside/outside) parameter is added to all field functions
+  (`getBHJM`) to specify the location of the observers relative to the magnet
+  body in order to increase performance
+  ([#717](https://github.com/magpylib/magpylib/issues/717),
+  [#608](https://github.com/magpylib/magpylib/issues/608))
+- Review of documentation and adding a few requested things
+  ([#685](https://github.com/magpylib/magpylib/issues/685), some of
+  [#659](https://github.com/magpylib/magpylib/issues/659))
+- Added mu0 at top level as `magpylib.mu_0`. The value of mu0 is taken from
+  scipy and follows the 2019 redefinition. All internal computations now include
+  this new value. ([#714](https://github.com/magpylib/magpylib/issues/714),
+  [#731](https://github.com/magpylib/magpylib/issues/731))
+- The core level now includes only the true bottom level implementations.
+  ([#727](https://github.com/magpylib/magpylib/issues/727))
+- As Matplotlib graphic representation of 3D objects is terrible, we decided to
+  go back to "arrow" graphic default mode when graphic backend is
+  "Matplotlib".([#735](https://github.com/magpylib/magpylib/issues/735))
+
+## [4.5.1] - 2023-12-28
+
+- Fixed a field computation issue where H-field resulting from axial
+  magnetization is computed incorrectly inside of Cylinders
+  ([#703](https://github.com/magpylib/magpylib/issues/703))
+
+## [4.5.0] - 2023-12-13
+
+- Added optional handedness parameter for Sensors
+  ([#687](https://github.com/magpylib/magpylib/pull/687))
+- Renaming classes: `Line`→`Polyline`, `Loop`→`Circle`. Old names are still
+  valid but will issue a `DeprecationWarning` and will eventually be removed in
+  the next major version ([#690](https://github.com/magpylib/magpylib/pull/690))
+- Rework CI/CD workflows ([#686](https://github.com/magpylib/magpylib/pull/686))
+
+## [4.4.1] - 2023-11-09
+
+- Fixed deployment release
+  ([#682](https://github.com/magpylib/magpylib/pull/682))
+- Fixed axis mismatch on show/hide of sensor arrows
+  ([#679](https://github.com/magpylib/magpylib/pull/679))
+- Documentation improvements
+  ([#673](https://github.com/magpylib/magpylib/pull/673))
+
+## [4.4.0] - 2023-09-03
+
+- Included self-intersection check in `TriangularMesh`
+  ([#622](https://github.com/magpylib/magpylib/pull/622))
+- Fixed incorrect edge case of TriangularMesh reorientation
+  ([#644](https://github.com/magpylib/magpylib/pull/644))
+- Discontinuous segments in `current.Line` are now accepted and correctly
+  treated as separate lines
+  ([#632](https://github.com/magpylib/magpylib/pull/632),
+  [#642](https://github.com/magpylib/magpylib/pull/642))
+- Objects can now be displayed with missing dimension and/or excitation
+  ([#640](https://github.com/magpylib/magpylib/pull/640))
+- Added magnetization and current arrows `sizemode` styling option (absolute or
+  scaled) ([#639](https://github.com/magpylib/magpylib/pull/639))
+- `Collection` objects now also have a default description when displayed
+  (number of children) ([#634](https://github.com/magpylib/magpylib/pull/634))
+- Many minor graphic improvements
+  ([#663](https://github.com/magpylib/magpylib/pull/663),
+  [#649](https://github.com/magpylib/magpylib/issues/649),
+  [#653](https://github.com/magpylib/magpylib/issues/653))
+- `legend` style option
+  ([#650](https://github.com/magpylib/magpylib/issues/650))
+- Changed unit naming in text to comply with DIN Norm 641
+  ([#614](https://github.com/magpylib/magpylib/issues/614))
+- Improved documentation now boasting a contribution guide, a news-blog, an
+  example and tutorial gallery, a getting started section and many other
+  improvements ([#621](https://github.com/magpylib/magpylib/issues/621),
+  [#596](https://github.com/magpylib/magpylib/issues/596),
+  [#580](https://github.com/magpylib/magpylib/issues/580))
+- Improved numerical stability of `CylinderSegment`,
+  ([#648](https://github.com/magpylib/magpylib/issues/648),
+  [#651](https://github.com/magpylib/magpylib/issues/651))
+
+## [4.3.0] - 2023-06-25
+
+- New `TriangularMesh` magnet class added to conveniently work with triangular
+  surface meshes instead of large collections of individual `Triangle` objects.
+  The `TriangularMesh` class performs important checks (closed, connected,
+  oriented) and can directly import Pyvista objects and for convex hull bodies.
+  ([#569](https://github.com/magpylib/magpylib/issues/569),
+  [#598](https://github.com/magpylib/magpylib/pull/598)).
+- Added magnetization coloring for `matplotlib` backend
+  ([#597](https://github.com/magpylib/magpylib/pull/597))
+- New automatic backend behavior, set to a dynamic default `auto` depending on
+  the current environment and the given `canvas`, if provided.
+  ([#617](https://github.com/magpylib/magpylib/pull/617))
+- Drop python 3.7 support, following python life cycle.
+  ([#616](https://github.com/magpylib/magpylib/pull/616))
+
+## [4.2.0] - 2023-01-27
+
+- (Re)introducing the powerful `misc.Triangle` class that can be used to compute
+  magnetic fields of arbitrarily shaped bodies by approximating their surface
+  with triangular faces.
+  ([#568](https://github.com/magpylib/magpylib/issues/568))
+- Introducing the `magnet.Tetrahedron` class as a derivate of the Triangle
+  class. ([#289](https://github.com/magpylib/magpylib/issues/289))
+- Change Pyvista plotting defaults when using `show(backend='pyvista')` to fit
+  better with other libraries.
+  ([#551](https://github.com/magpylib/magpylib/issues/551))
+- Added code of conduct attempting to align with NumFocus standards
+  ([#558](https://github.com/magpylib/magpylib/issues/558))
+- Improved Loop field computation in terms of performance and numerical
+  stability ([#374](https://github.com/magpylib/magpylib/issues/374))
+- Added `magnetization.mode` style to allow showing magnetization direction for
+  any backend ([#576](https://github.com/magpylib/magpylib/pull/576))
+- Documentation changes:
+  - Correct conda install command
+  - Integration of Triangle and Tetrahedron
+  - Changed example gallery substructure
+  - Rewritten and added some passages
+- Fixed some bugs, minor performance increase, internal refactoring
+
+## [4.1.2] - 2023-01-15
+
+- Fixed wrong magnetization arrow direction for some edge cases
+  ([#570](https://github.com/magpylib/magpylib/discussions/570),
+  [#571](https://github.com/magpylib/magpylib/issues/571),
+  [#572](https://github.com/magpylib/magpylib/pull/572))
+- Fixed cryptic `getB`/`getH` error message
+  ([#562](https://github.com/magpylib/magpylib/issues/562),
+  [#563](https://github.com/magpylib/magpylib/pull/563))
+
+## [4.1.1] - 2022-08-11
+
+- Fixed inverted y and z axes colors for sensor representations
+  ([#556](https://github.com/magpylib/magpylib/pull/556))
+
+## [4.1.0] - 2022-08-08
+
+- Field computation `getB`/`getH` now supports 2D
+  [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe)
+  in addition to the `numpy.ndarray` as output type.
+  ([#523](https://github.com/magpylib/magpylib/pull/523))
+- Internal `getB`/`getH` refactoring. The direct interface with `'Line'` source
+  argument now also accepts `'vertices'` as argument.
+  ([#540](https://github.com/magpylib/magpylib/pull/540))
+- Complete plotting backend rework to prepare for easy implementation of new
+  backends, with minimal maintenance.
+  ([#539](https://github.com/magpylib/magpylib/pull/539))
+- New [Pyvista](https://docs.pyvista.org/) plotting backend
+  ([#548](https://github.com/magpylib/magpylib/pull/548))
+- Improvements on the
+  [documentation](https://magpylib.readthedocs.io/en/latest/)
+
+## [4.0.4] - 2022-06-09
+
+- Exclude redundant properties with `_all` suffix in the `.describe()` method
+  ([#534](https://github.com/magpylib/magpylib/pull/534))
+- Docstring improvements ([#535](https://github.com/magpylib/magpylib/pull/535))
+
+## [4.0.3] - 2022-05-13
+
+- Fixed copy order Bug ([#530](https://github.com/magpylib/magpylib/issues/530))
+
+## [4.0.2] - 2022-05-04
+
+- Fixed magnetization coloring with mesh grouping (Plotly)
+  ([#526](https://github.com/magpylib/magpylib/pull/526))
+- Allow float color quadruples
+  ([#529](https://github.com/magpylib/magpylib/pull/529))
+
+## [4.0.1] - 2022-04-29
+
+- Graphic performance update for Plotly when showing a large number of objects.
+  ([#524](https://github.com/magpylib/magpylib/pull/524))
+
+## [4.0.0] - 2022-04-14
+
+This is a major update that includes
+
+- API changes
+- New features
+- Improved internal workings
+
+### Magpylib class changes/fixes:
+
+- `Box` class renamed to `Cuboid`.
+  ([#350](https://github.com/magpylib/magpylib/issues/350))
+- `Circular` class renamed to `Loop`.
+  ([#402](https://github.com/magpylib/magpylib/pull/402))
+- New `CylinderSegment` class with dimension `(r1,r2,h,phi1,phi2)` with the
+  inner radius `r1`, the outer radius `r2` the height `h` and the cylinder
+  section angles `phi1 < phi2`.
+  ([#386](https://github.com/magpylib/magpylib/issues/386),
+  [#385](https://github.com/magpylib/magpylib/issues/385),
+  [#484](https://github.com/magpylib/magpylib/pull/484),
+  [#480](https://github.com/magpylib/magpylib/issues/480))
+- New `CustomSource` class for user defined field functions
+  ([#349](https://github.com/magpylib/magpylib/issues/349),
+  [#409](https://github.com/magpylib/magpylib/issues/409),
+  [#411](https://github.com/magpylib/magpylib/pull/411),
+  [#506](https://github.com/magpylib/magpylib/pull/506))
+- All Magpylib objects can now be initialized without excitation and dimension
+  attributes.
+- All classes now have the `parent` attribute to reference to a collection they
+  are part of. Any object can only have a single parent.
+- All classes have the `describe` method which gives a quick object property
+  overview.
+
+### Field computation changes/fixes:
+
+- New computation core. Added top level subpackage `magpylib.core` where all
+  field implementations can be accessed directly without the
+  position/orientation interface.
+  ([#376](https://github.com/magpylib/magpylib/issues/376))
+- Direct interface functions `getBdict` and `getHdict` (previously `getBv` and
+  `getHv`) are now integrated into `getB` and `getH`. See docs for details
+  ([#449](https://github.com/magpylib/magpylib/pull/449))
+- Generally improved field expressions:
+  ([#374](https://github.com/magpylib/magpylib/issues/374))
+  - Negative dimension input taken as absolute when only positive dimensions are
+    allowed.
+  - Scale invariant field evaluations.
+  - Special cases caught within 1e-15 rtol and atol to account for numerical
+    imprecision with positioning (e.g. object rotation).
+  - Suppress NumPy divide/invalid warnings. return `np.nan` as `(0,0,0)` (e.g.
+    on magnet edges or on line currents) and allow return of `np.inf`.
+  - New closed form implementation for `Cylinder` with diametral magnetization
+    is much faster (100-1000x) and numerically stable for small `r`.
+    ([#404](https://github.com/magpylib/magpylib/issues/404),
+    [#370](https://github.com/magpylib/magpylib/issues/370))
+  - Improved numerical stability of current loop field. Now 12-14 correct digits
+    everywhere. ([#374](https://github.com/magpylib/magpylib/issues/374))
+  - Fixed `Collection` of `Lines` field computation error.
+    ([#368](https://github.com/magpylib/magpylib/issues/368))
+- Object oriented interface fixes and modifications:
+  - Improved performance of `getB` and `getH`.
+  - Fixed array dimension wrongly reduced when `sumup=True` and `squeeze=False`
+    in `getB` and `getH` functions
+    ([#425](https://github.com/magpylib/magpylib/issues/425),
+    [#426](https://github.com/magpylib/magpylib/pull/426))
+  - Minimal non-squeeze output shape is (1,1,1,1,3), meaning that a single pixel
+    is now also represented.
+    ([#493](https://github.com/magpylib/magpylib/pull/493))
+- With the new kwarg `pixel_agg` it is now possible to apply a NumPy function
+  with reducing functionality (like `mean`, `min`, `average`) to the pixel
+  output. In this case, it is allowed to provide `getB` and `getH` with
+  different observer input shapes.
+  ([#503](https://github.com/magpylib/magpylib/pull/503))
+
+### Major graphic output overhaul:
+
+- Styles:
+  - All object now have the `style` attribute for graphical output
+    customization. Arguments can be passed as dictionaries, class attributes or
+    with underscore magic.
+  - Style defaults stored in `magpylib.defaults.display`.
+    ([#291](https://github.com/magpylib/magpylib/issues/291),
+    [#396](https://github.com/magpylib/magpylib/pull/396))
+  - Possibility to add a custom 3D-model to any object.
+    ([#416](https://github.com/magpylib/magpylib/pull/416))
+- `display` now called `show`, to be more in-line with standard graphic
+  backends. Functionality completely overhauled to function with styles.
+  ([#453](https://github.com/magpylib/magpylib/pull/453),
+  [#451](https://github.com/magpylib/magpylib/issues/451))
+  - New `show` arguments replace previous ones. Some are now handed over through
+    styles.
+    - `axis` ➡️ `canvas`
+    - `show_direction` ➡️ `style_magnetization_show`
+    - `show_path` ➡️ `style_path_show`
+      ([#453](https://github.com/magpylib/magpylib/pull/453))
+    - `size_sensors`&`size_dipoles` ➡️ `style_size`
+    - `size_direction` ➡️ `style_magnetization_size`
+    - new `zoom` option
+- Plotly as new optional graphic backend. 🚀
+  ([#396](https://github.com/magpylib/magpylib/pull/396),
+  [#353](https://github.com/magpylib/magpylib/issues/353))
+  - `plotly` is now automatically installed with Magpylib.
+    ([#395](https://github.com/magpylib/magpylib/issues/395))
+  - Interactive path `animation` option in `show`.
+    ([#453](https://github.com/magpylib/magpylib/pull/453))
+  - Automatic Matplotlib <-> Plotly style input translations
+    ([#452](https://github.com/magpylib/magpylib/issues/452),
+    [#454](https://github.com/magpylib/magpylib/pull/454))
+- Misc:
+  - Added `matplotlib` pixel display
+    ([#279](https://github.com/magpylib/magpylib/issues/279))
+  - Sensors have their own color now
+    ([#483](https://github.com/magpylib/magpylib/pull/483))
+  - UI fix empty display
+    ([#401](https://github.com/magpylib/magpylib/issues/401))
+  - Error msg when `show` is called without argument
+    ([#448](https://github.com/magpylib/magpylib/issues/448))
+
+### New documentation:
+
+- Completely new structure and layout.
+  ([#399](https://github.com/magpylib/magpylib/issues/399),
+  [#294](https://github.com/magpylib/magpylib/issues/294))
+- Binder links and live code.
+  ([#389](https://github.com/magpylib/magpylib/issues/389))
+- Example galleries with practical user examples
+- Guidelines for advanced subclassing of `Collection` to form complex dynamic
+  compound objects that seamlessly integrate into the Magpylib interface.
+
+### Geometry interface modification
+
+- Added all Scipy Rotation forms as rotation object methods.
+  ([#427](https://github.com/magpylib/magpylib/pull/427))
+- `move` and `rotate` inputs differentiate between scalar and vector input.
+  Scalar input is applied to the whole path vector input is merged.
+  ([#438](https://github.com/magpylib/magpylib/discussions/438),
+  [#444](https://github.com/magpylib/magpylib/issues/444),
+  [#442](https://github.com/magpylib/magpylib/issues/443))
+- `move` and `rotate` methods have default `start='auto'` (scalar input:
+  `start=0`-> applied to whole path, vector input: `start=len_path`-> append)
+  instead of `start=-1`.
+- `move` and `rotate` methods maintain collection geometry when applied to a
+  collection.
+- Improved `position` and `orientation` setter methods in line with `move` and
+  `rotate` functionality and maintain `Collection` geometry.
+- Removed `increment` argument from `move` and `rotate` functions
+  ([#438](https://github.com/magpylib/magpylib/discussions/438),
+  [#444](https://github.com/magpylib/magpylib/issues/444))
+
+### Modifications to the `Collection` class
+
+- Collections can now contain `Source`, `Sensor` and other `Collection` objects
+  and can function as source and observer inputs in `getB` and `getH`.
+  ([#502](https://github.com/magpylib/magpylib/pull/502),
+  [#410](https://github.com/magpylib/magpylib/issues/410),
+  [#415](https://github.com/magpylib/magpylib/pull/415),
+  [#297](https://github.com/magpylib/magpylib/issues/297))
+- Instead of the property `Collection.sources` there are now the
+  `Collection.children`, `Collection.sources`, `Collection.sensors` and
+  `Collection.collections` properties. Setting these collection properties will
+  automatically override parents.
+  ([#446](https://github.com/magpylib/magpylib/issues/446),
+  [#502](https://github.com/magpylib/magpylib/pull/502))
+- `Collection` has it's own `position`, `orientation` and `style`.
+  ([#444](https://github.com/magpylib/magpylib/issues/444),
+  [#461](https://github.com/magpylib/magpylib/issues/461))
+- All methods applied to a collection maintain relative child-positions in the
+  local reference frame.
+- Added `__len__` dunder for `Collection`, so that `Collection.children` length
+  is returned. ([#383](https://github.com/magpylib/magpylib/issues/383))
+- `-` operation was removed.
+- `+` operation now functions as `a + b = Collection(a, b)`. Warning:
+  `a + b + c` now creates a nested collection !
+- Allowed `Collection`, `add` and `remove` input is now only `*args` or a single
+  flat list or tuple of Magpylib objects.
+- `add` and `remove` have some additional functionality related to child-parent
+  relations.
+- The `describe` method gives a great Collection tree overview.
+
+### Other changes/fixes:
+
+- Magpylib error message improvement. Msg will now tell you what input is
+  expected.
+- Magpylib object `copy` method now works properly
+  ([#477](https://github.com/magpylib/magpylib/pull/477),
+  [#470](https://github.com/magpylib/magpylib/pull/470),
+  [#476](https://github.com/magpylib/magpylib/issues/476))
+- Defaults and input checks
+  ([#406](https://github.com/magpylib/magpylib/issues/406))
+  - `magpylib.Config` parameters are now in `magpylib.defaults`.
+    ([#387](https://github.com/magpylib/magpylib/issues/387))
+  - `config.ITERCYLINDER` is now obsolete. The iterative solution replaced by a
+    new analytical expression.
+  - `config.inputchecks` is removed - input checks are always performed.
 
-All notable changes to magpylib are documented here.
+---
+
+## [3.0.5] - 2022-04-26
+
+- fix docs build
 
 ---
 
-# Releases
+## [3.0.4] - 2022-02-17
 
-## [2.3.0b] - 2020-01-17
+- fix `Collection` operation tests
 
-### Changed
-- Improved performance of getB for diametral magnetized Cylinders by 20%.
-- GetB of Line current now uses vectorized code which leads to massive performance enhancement.
-- **IMPORTANT:** position arguments of `getBv` functions have been flipped! First comes the source position POSm THEN the observer position POSo!
-- - getB(pos) now takes single AND vector position arguments. If a vector is handed to getB it will automatically execute vectorized code from the vector module.
+---
+
+## [3.0.3] - 2022-02-17
+
+### Fixed
+
+- When adding with `Source + Collection` to create a new `Collection`, the
+  original now remains unaffected
+  ([#472](https://github.com/magpylib/magpylib/issues/472))
+
+---
+
+## [3.0.2] - 2021-06-27
+
+- Update release version and license year
+  ([#343](https://github.com/magpylib/magpylib/pull/343),
+  [#344](https://github.com/magpylib/magpylib/pull/344))
+
+---
+
+## [3.0.1] - 2021-06-27
+
+- Added deployment automation
+  ([#260](https://github.com/magpylib/magpylib/issues/260),
+  [#296](https://github.com/magpylib/magpylib/issues/296),
+  [#341](https://github.com/magpylib/magpylib/pull/341),
+  [#342](https://github.com/magpylib/magpylib/pull/342))
+
+---
+
+## [3.0.0] - 2021-06-27
+
+This is a major update that includes
+
+- API changes
+- New features
+- Improved internal workings
 
 ### Added
-- completed the library vector functionality adding magnet Cylinder, moment Dipole, current Circular and Line. This includes adding several private vectorized functions (e.g. ellipticV) to mathLib_vector, adding respective tests and docu examples.
+
+- New `orientation` property:
+  - The `orientation` attribute stores the relative rotation of an object with
+    respect to the reference orientation (defined in each class docstring).
+  - The default (`orientation=None`) corresponds to a unit rotation.
+  - `orientation` is stored as a `scipy.spatial.transform.Rotation` object.
+  - Calling the attribute `source.orientation` returns a Scipy Rotation object
+    `R`.
+  - Make use of all advantages of this great Scipy package:
+    - define `R.from_rotvec()` or `R.from_quat()` or ...
+    - view with `R.as_rotvec()` or `R.as_quat()` or ...
+    - combine subsequent rotations `R1 * R2 * R3`
+- Sensor pixel:
+  - The new `Sensor(position, pixel, orientation)` class has the argument
+    `pixel` which is `(0,0,0)` by default and refers to pixel positions inside
+    the Sensor (in the Sensor local CS). `pixel` is an arbitrary array_like of
+    the shape (N1, N2, ..., 3).
+- Geometry paths:
+
+  - The `position` and `orientation` attributes can now store paths in the
+    global CS. For a path of length M the attribute `position` is an array of
+    the shape (M,3) and `orientation` is a Rotation object with length M. Each
+    path position is associated with a respective rotation.
+  - Field computations `getB()` and `getH()` will evaluate the field for all
+    source path positions.
+  - Paths can be set by hand `position = X`, `orientation = Y`, but they can
+    also conveniently be generated using the `rotate` and `move` methods.
+  - Paths can be shown via the `show_path=True` kwarg in `display()`. By setting
+    `show_path=x` the object will be displayed at every `x`'th path step. This
+    helps to follow up on object rotation along the path.
+  - All objects have a `reset_path()` method defined to set their paths to
+    `position=(0,0,0)` and `orientation=None`.
+
+- Streamlining operation with all Magpylib objects:
+  - All objects (Sensors, Sources, Collections) have additional direct access to
+    - `.display()` method for quick self-inspection.
+    - `getB()` and `getH()` methods for fast field computations
+    - `__repr__` attribute defined and will return their type and their `id`.
+- Other new features:
+  - The top-level `Config` allows users to access and edit Magpylib default
+    values.
+
+### Changed
+
+- Renamed modules:
+  - `.magnet` and `.current` sub-packages were moved to the top level.
+  - The `.moment` sub-package was renamed to `.misc` and was moved to the top
+    level.
+  - The `.vector` sub-package was completely removed. Functionalities are mostly
+    replaced by new top-level function `getBv()`.
+  - The `.math` sub-package was removed. Functionalities are mostly provided by
+    the `scipy - Rotation` package.
+- Renamed functions:
+  - The top level function `displaySystem()` was renamed to `display()`.
+- Renamed attributes (parameters cannot be initialized in their short forms
+  anymore):
+
+  - `angle` and `axis` are replaced by `orientation`
+  - `dimension` is replaced by `diameter` for Loop and Sphere classes.
+  - `angle`&`axis` are replaced by `orientation`.
+
+- Modified rotate methods:
+  - The class methods `.rotate(angle, axis, anchor)` have been replaced by a new
+    `.rotate(rotation, anchor, increment, start)` method where `rotation` is a
+    scipy `Rotation` object.
+  - The original angle-axis-anchor rotation is now provided by the new method
+    `.rotate_from_angax(angle, axis, anchor, increment, start, degrees)`.
+    - The argument `axis` can now easily be set to the global CS axes with
+      `"x"`, `"y"`, `"z"`.
+    - The anchor argument `anchor=0` represents the origin `(0,0,0)`.
+    - `angle` argument is in units of deg by default. It can now be set to rad
+      using the `degrees` argument.
+  - The "move"-class method is now `.move(displacement, increment, start)`
+  - Rotation and move methods can now be used to generate paths using vector
+    input and the `increment` and `start` arguments.
+  - All operations can now be chained (e.g. `.move_by().rotate().move_to()`)
+- Miscellaneous:
+  - `getB(pos)` now takes single AND vector position arguments. If a vector is
+    handed to getB it will automatically execute vectorized code from the vector
+    module.
+  - In a finite region (size defined by `Config.EDGESIZE`) about magnet edges
+    and line currents the field evaluates to `(0,0,0)` instead of
+    `(NaN, NaN, NaN)`. Special case catching reduces performance slightly.
+
+### Updated
+
+- Improved Computation:
+  - The Box field is now more stable. Numerical instabilities in the outfield
+    were completely removed.
+- Updated Field computation interface
+  - There are two fundamental arguments for field computation:
+    - The argument `sources` refers to a source/Collection or to a 1D list of L
+      sources and/or Collections.
+    - The argument `observers` refers to a set of positions of shape (N1, N2,
+      ..., 3) or a Sensor with `pixel` shape (N1, N2, ..., 3) or a 1D list of K
+      Sensors.
+  - With Magpylib3 there are several ways to compute the field:
+    1. `source.getB(*observers)`
+    2. `sensor.getB(*sources)`
+    3. `magpylib.getB(sources, observers)`
+    - The output shape is always (L, M, K, N1, N2, ..., 3) with L sources, M
+      path positions, K sensors and N (pixel) positions.
+    - Objects with shorter paths will be considered as static once their path
+      ends while other paths continue.
+    4. `magpylib.getBv(**kwargs)` gives direct access to the field formulas and
+       mostly replaces the `getBv_XXX()` functionality of v2. All inputs must be
+       arrays of length N or of length 1 (constants will be tiled).
+  - While `getBv` is the fastest way to compute the fields it is much more
+    convenient to use `getB()` which mostly provides the same performance.
+    Specifically,the new `getB()` automatically groups all inputs for combined
+    vectorized evaluation. This leads to a massive speedup when dealing with
+    large Collections of similar sources.
+  - In addition to `getB`, the new `getH` returns the field in kA/m.
+
+### Removed
+
+- the kwarg `niter=50` does not exist anymore for the Cylinder field
+  computation. The functionality was completely replaced by the config setting
+  `Config.ITER_CYLINDER=50`.
 
 ---
 
-## [2.2.0b] - 2019-12-27
-- unreleased version
+## [2.3.0b] - 2020-01-17
+
+### Changed
+
+- Improved performance of `getB` for diametral magnetized Cylinders by 20%.
+- `getB` of Line current now uses vectorized code which leads to massive
+  performance enhancement.
+- **IMPORTANT:** position arguments of `getBv` functions have been flipped!
+  First comes the source position POSm THEN the observer position POSo!
+
+### Added
+
+- completed the library vector functionality adding magnet Cylinder, moment
+  Dipole, current Loop and Line. This includes adding several private vectorized
+  functions (e.g. ellipticV) to mathLib_vector, adding respective tests and docs
+  examples.
 
 ---
 
 ## [2.1.0b] - 2019-12-06
 
 ### Added
+
 - Docstrings for vector functions.
-- displaySystem kwarg `figsize`
+- `displaySystem` kwarg `figsize`
 - bringing documentation up to speed
 
-### Fixes
+### Fixed
+
 - init file bug
 
 ---
 
 ## [2.0.0b] - 2019-11-29
+
+This is a major update that includes
+
+API changes New features Improved internal workings
+
 ### Changed
+
 - Restructuring
   - displaySystem is now a top-level function, not a Collection method anymore.
-  - getBsweep and multiprocessing options have been completely removed, this functionality
-    should be overtaken by the new vector functionality which uses the numpy native vectorized 
-    code paradigm. If mkl library is set (test by numpy.show_config()) numpy will also 
-    automatically use multiporcessing. Code parallelization at magpylib level should be done
-    by hand.
-- Docstrings are adjusted to work better with intellisense. (Problems with *.rst code)
-- public rotatePosition() is now called angleAxisRotation(), former private angleAxisRotation
-    is now called angleAxisRotation_priv().
+  - getBsweep and multiprocessing options have been completely removed, this
+    functionality should be overtaken by the new vector functionality which uses
+    the numpy native vectorized code paradigm. If mkl library is set (test by
+    numpy.show_config()) numpy will also automatically use multiprocessing. Code
+    parallelization at magpylib level should be done by hand.
+- Docstrings are adjusted to work better with intellisense. (Problems with
+  \*.rst code)
+- public rotatePosition() is now called angleAxisRotation(), former private
+  angleAxisRotation is now called angleAxisRotation_priv().
 - Major rework of the documentation and examples.
 
 ### Added
-- Performance computation trough vector functionality included in new top-level subpackge "vector"
+
+- Performance computation through vector functionality included in new top-level
+  subpackage "vector"
 - Vectorized versions of math functions added to "math" subpackage
 
 ---
 
 ## [1.2.1b0] - 2019-07-31
+
 ### Changed
-- Optimized getB call (utility integrated)
+
+- Optimized `getB` call (utility integrated)
 - Improved Documentation (added Sensor class v1)
 
 ---
 
 ## [1.2.0b0] - 2019-07-16
+
 ### Added
+
 - Sensor Class
-  - This allows users to create a coordinate system-enabled Sensor object, which can be placed, rotated, moved and oriented. 
-  - This object can take the B-Field of a system (be it single source or a Collection) with the added functionality of having its own reference in the coordinate space, allowing users to easily acquire relative B-Field measurements of a system from an arbitrarily placed sensor object. 
-  - Sensors in a list may be displayed in the `Collection.displaySystem()` by using the `sensors` keyword argument.
-- Added content to the `__repr__` builtin to all source classes for quick console evaluations, simply call a defined object in your Python shell to print out its attributes.
+  - This allows users to create a coordinate system-enabled Sensor object, which
+    can be placed, rotated, moved, and oriented.
+  - This object can take the B-Field of a system (be it single source or a
+    Collection) with the added functionality of having its own reference in the
+    coordinate space, allowing users to easily acquire relative B-Field
+    measurements of a system from an arbitrarily placed sensor object.
+  - Sensors in a list may be displayed in the `Collection.displaySystem()` by
+    using the `sensors` keyword argument.
+- Added content to the `__repr__` built-in to all source classes for quick
+  console evaluations, simply call a defined object in your Python shell to
+  print out its attributes.
+
 ### Changed
-- Edge cases in field calculations now return a proper [RuntimeWarning](https://docs.python.org/3/library/exceptions.html#RuntimeWarning) instead of console prints
+
+- Edge cases in field calculations now return a proper
+  [RuntimeWarning](https://docs.python.org/3/library/exceptions.html#RuntimeWarning)
+  instead of console prints
+
 ### Fixed
+
 - Unused imports and variables
 
 ---
 
 ## [1.1.1b0] - 2019-06-25
-### Added 
+
+### Added
+
 - Changelog
+
 ### Changed
-- Change `Collection.displaySystem()` not having the `block=False` setting for matplotlib's `pyplot.show()` by default, this meant that outside interactive mode calling this function would hang the script until the plot was closed.
-  - If for some reason you want to block the application, you may still use `Collection.displaySystem()`'s `suppress=True` kwarg then call pyplot.show() normally. 
+
+- Change `Collection.displaySystem()` not having the `block=False` setting for
+  matplotlib's `pyplot.show()` by default, this meant that outside interactive
+  mode calling this function would hang the script until the plot was closed.
+  - If for some reason you want to block the application, you may still use
+    `Collection.displaySystem()`'s `suppress=True` kwarg then call pyplot.show()
+    normally.
   - This should cause no API changes, if you have problems please notify us.
 
 ### Fixed
-- Fix multiprocessing enabled `Collection.getBsweep()` for lots of objects with few positions causing great performance loss. This functionality now behaves as expected for the use case.
-- Fix `Collection.displaySystem()`'s drawing of Dipole objects in external axes (plots) using the `subplotAx` kwarg crashing the application. This functionality now behaves as expected for the use case.
+
+- Fixed multiprocessing enabled `Collection.getBsweep()` for lots of objects
+  with few positions causing great performance loss. This functionality now
+  behaves as expected for the use case.
+- Fixed `Collection.displaySystem()`'s drawing of Dipole objects in external
+  axes (plots) using the `subplotAx` kwarg crashing the application. This
+  functionality now behaves as expected for the use case.
 
 ---
 
 ## [1.1.0b0] - 2019-06-14
+
 ### Added
+
 - Implemented one new kwarg for `Collection.displaySystem()`:
 
-    > `subplotAx=None`
+  > `subplotAx=None`
+
         Draw into a subplot axe that already exists. The subplot needs to be 3D projected
-        
-  This allows for creating side-by-side plots using displaySystem.
-  Figure information must be set manually in pyplot.figure() in order to not squash the plots upon subplotting.
-    
+
+  This allows for creating side-by-side plots using `displaySystem`. Figure
+  information must be set manually in pyplot.figure() in order to not squash the
+  plots upon sub plotting.
 
     <details>
     <summary> Click here for Example </summary>
 
-    Code: https://gist.github.com/lucasgcb/77d55f2fda688e2fb8e1e4a68bb830b8
+  Code: https://gist.github.com/lucasgcb/77d55f2fda688e2fb8e1e4a68bb830b8
 
-    **Output:**
-    ![image](https://user-images.githubusercontent.com/7332704/58973138-86b4a600-87bf-11e9-9e63-35892b7a6713.png)
+  **Output:**
+  ![image](https://user-images.githubusercontent.com/7332704/58973138-86b4a600-87bf-11e9-9e63-35892b7a6713.png)
 
     </details>
-    
+
 ### Changed
 
-- `getBsweep()` for Collections and Sources now always returns a numpy array
-- Zero-length segments in Line sources now return `[0,0,0]` and a warning, making it easier to draw spirals without letting users do this unaware.
+- `getBsweep()` for Collections and Sources now always returns a NumPy array
+- Zero-length segments in Line sources now return `[0,0,0]` and a warning,
+  making it easier to draw spirals without letting users do this unaware.
 
 ### Fixed
+
 - Added a workaround fix for a rotation bug we are still working on.
 
 ---
@@ -126,7 +796,7 @@ All notable changes to magpylib are documented here.
 
 ### Added
 
-- `MANIFEST.in` file containing the LICENSE for bundling in PyPi
+- `MANIFEST.in` file containing the LICENSE for bundling in PyPI
 
 ---
 
@@ -136,9 +806,7 @@ All notable changes to magpylib are documented here.
 
 - Issue and Pull Request Templates to Repository
 - Continuous Integration settings (Azure and Appveyor)
-- Code Coverage Reports with codecov
-
-
+- Code Coverage Reports with Codecov
 
 ### Removed
 
@@ -148,21 +816,52 @@ All notable changes to magpylib are documented here.
 
 ## [1.0.0b0] - 2019-05-21
 
-The first official release of the magpylib library. 
+The first official release of the Magpylib library.
 
 ### Added
 
 - Source classes:
-   - Box
-   - Cylinder
-   - Sphere
-   - Circular Current
-   - Current Line
-   - Dipole
+  - Box
+  - Cylinder
+  - Sphere
+  - Loop Current
+  - Current Line
+  - Dipole
 - Collection class
 
 ---
 
+[Unreleased]: https://github.com/magpylib/magpylib/compare/5.1.1...HEAD
+[5.1.1]: https://github.com/magpylib/magpylib/compare/5.1.0...5.1.1
+[5.1.0]: https://github.com/magpylib/magpylib/compare/5.0.4...5.1.0
+[5.0.4]: https://github.com/magpylib/magpylib/compare/5.0.3...5.0.4
+[5.0.3]: https://github.com/magpylib/magpylib/compare/5.0.2...5.0.3
+[5.0.2]: https://github.com/magpylib/magpylib/compare/5.0.1...5.0.2
+[5.0.1]: https://github.com/magpylib/magpylib/compare/5.0.0...5.0.1
+[5.0.0]: https://github.com/magpylib/magpylib/compare/4.5.1...5.0.0
+[4.5.1]: https://github.com/magpylib/magpylib/compare/4.5.0...4.5.1
+[4.5.0]: https://github.com/magpylib/magpylib/compare/4.4.0...4.5.0
+[4.4.1]: https://github.com/magpylib/magpylib/compare/4.4.0...4.4.1
+[4.4.0]: https://github.com/magpylib/magpylib/compare/4.3.0...4.4.0
+[4.3.0]: https://github.com/magpylib/magpylib/compare/4.2.0...4.3.0
+[4.2.0]: https://github.com/magpylib/magpylib/compare/4.1.2...4.2.0
+[4.1.2]: https://github.com/magpylib/magpylib/compare/4.1.1...4.1.2
+[4.1.1]: https://github.com/magpylib/magpylib/compare/4.1.0...4.1.1
+[4.1.0]: https://github.com/magpylib/magpylib/compare/4.0.4...4.1.0
+[4.0.4]: https://github.com/magpylib/magpylib/compare/4.0.3...4.0.4
+[4.0.3]: https://github.com/magpylib/magpylib/compare/4.0.2...4.0.3
+[4.0.2]: https://github.com/magpylib/magpylib/compare/4.0.1...4.0.2
+[4.0.1]: https://github.com/magpylib/magpylib/compare/4.0.0...4.0.1
+[4.0.0]: https://github.com/magpylib/magpylib/compare/3.0.4...4.0.0
+[3.0.5]: https://github.com/magpylib/magpylib/compare/3.0.4...3.0.5
+[3.0.4]: https://github.com/magpylib/magpylib/compare/3.0.3...3.0.4
+[3.0.3]: https://github.com/magpylib/magpylib/compare/3.0.2...3.0.3
+[3.0.2]: https://github.com/magpylib/magpylib/compare/3.0.1...3.0.2
+[3.0.1]: https://github.com/magpylib/magpylib/compare/3.0.0...3.0.1
+[3.0.0]: https://github.com/magpylib/magpylib/compare/2.3.0-beta...3.0.0
+[2.3.0b]: https://github.com/magpylib/magpylib/compare/2.1.0-beta...2.3.0-beta
+[2.1.0b]: https://github.com/magpylib/magpylib/compare/2.0.0-beta...2.1.0-beta
+[2.0.0b]: https://github.com/magpylib/magpylib/compare/1.2.1-beta...2.0.0-beta
 [1.2.1b0]: https://github.com/magpylib/magpylib/compare/1.2.0-beta...1.2.1-beta
 [1.2.0b0]: https://github.com/magpylib/magpylib/compare/1.1.1-beta...1.2.0-beta
 [1.1.1b0]: https://github.com/magpylib/magpylib/compare/1.1.0-beta...1.1.1-beta
@@ -171,7 +870,6 @@ The first official release of the magpylib library.
 [1.0.1b0]: https://github.com/magpylib/magpylib/compare/1.0.0-beta...1.0.1-beta
 [1.0.0b0]: https://github.com/magpylib/magpylib/releases/tag/1.0.0-beta
 
----
-
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
\ No newline at end of file
+and this project adheres to
+[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..880eae093
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,118 @@
+# Code of Conduct
+
+## Our Pledge
+
+We as Magpylib members, contributors, and leaders pledge to make participation
+in our community a harassment-free experience for everyone, regardless of age,
+body size, visible or invisible disability, ethnicity, sex characteristics,
+gender identity and expression, level of experience, education, socio-economic
+status, nationality, personal appearance, race, caste, color, religion, or
+sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+  community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+  any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+  without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and applies when an
+individual is officially representing the community in public spaces. Examples
+of representing our community include using an official e-mail address, posting
+via an official social media account, or acting as an appointed representative
+at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[magpylib@gmail.com](mailto:magpylib@gmail.com). All complaints will be reviewed
+and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the
+[Contributor Covenant](https://www.contributor-covenant.org/).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..7575c2340
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,72 @@
+# Contribution Guide
+
+The success of Magpylib relies on its user-friendliness. Your feedback and
+participation in discussions is strongly encouraged. Ask questions about
+Magpylib. Tell us what you like and what you dislike. Start general discussions
+in our informal [Discussions](https://github.com/magpylib/magpylib/discussions)
+channel on GitHub.
+
+We use GitHub
+[Issues and Milestones](https://github.com/magpylib/magpylib/issues) to plan and
+track the Magpylib project. Open new Issues to report a bug, to point out a
+problem, or to make a feature request, e.g. following a fruitful discussion.
+Within the issue we will define in detail what should be done. For small bug
+fixes, code cleanups, and other small improvements it's not necessary to create
+issues.
+
+Always feel free to reach out through the official email <magpylib@gmail.com>.
+
+## How to Contribute with Coding
+
+You are most welcome to become a project contributor by helping us with coding.
+This includes the implementation of new features, fixing bugs, code cleanup and
+restructuring as well as documentation improvements. Please abide by the
+following procedure to make things easy for us to review and to manage the
+project.
+
+1. Fork the Magpylib repository to your GitHub account
+2. Edit your new repository (good practice: clone to local machine, edit, push
+   changes).
+3. Rebase your new repository (or pull from upstream) regularly to include
+   upstream changes.
+4. Once your changes are complete (see [Coding requirements](coding-requ)
+   below), or you want some feedback, make a pull request (PR) targeting the
+   Magpylib repository. Explain your feature in the PR, and/or refer to the
+   respective issue that you address. Add illustrative code examples.
+5. Once a PR is created, our pipeline tests will automatically check your code.
+   A Magpylib member will review your contributions and discuss your changes.
+   Possible improvements will be requested.
+6. When satisfied, the reviewer will merge your PR and you become an official
+   Magpylib contributor.
+
+(coding-requ)=
+
+## Coding Requirements
+
+- All code is well documented and all top level doc strings abide by the
+  [NumPy docstring style](https://numpydoc.readthedocs.io/en/latest/format.html).
+- All unit tests are running. We recommend using the
+  [Pytest](https://docs.pytest.org/en/7.4.x/) package.
+- New unit tests are written aiming for 100% code coverage. We use
+  [Coverage](https://coverage.readthedocs.io/en/) to test this.
+- [Pylint](https://pylint.readthedocs.io/en/stable/) rates your code 10/10 and
+  there are no formatting issues reported (e.g. line-too-long).
+- Your code is PEP8 compliant and formatted with
+  [Black](https://black.readthedocs.io/en/stable/) default settings.
+
+We strongly suggest that you use the [Pre-Commit](https://pre-commit.com/) hooks
+that apply important code checks which each commit.
+
+## For Your Orientation
+
+The Magpylib repository is structured as follows:
+
+- **magpylib**
+  - **magpylib**: the actual package.
+    - **\_src**: source code
+    - Other files generate the interface
+  - **docs**: documentation that is displayed on
+    [Read the Docs](https://readthedocs.org/) using
+    [Sphinx](https://www.sphinx-doc.org/en/master/).
+  - **tests**: unit tests
+  - Other files are project configuration files, Readme, ...
diff --git a/LICENSE b/LICENSE
index 29ebfa545..9de07e4cb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,661 +1,16 @@
-                    GNU AFFERO GENERAL PUBLIC LICENSE
-                       Version 3, 19 November 2007
+Copyright (c) 2022, Silicon Austria Labs, Magpylib Developers.
+All rights reserved.
 
- Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
 
-                            Preamble
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
 
-  The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
+2. Redistributions in binary form must reproduce the above
+   copyright notice, this list of conditions and the following
+   disclaimer in the documentation and/or other materials provided
+   with the distribution.
 
-  The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works.  By contrast,
-our General Public Licenses are intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.
-
-  When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
-  Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
-  A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate.  Many developers of free software are heartened and
-encouraged by the resulting cooperation.  However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
-  The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community.  It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server.  Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
-  An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals.  This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
-  The precise terms and conditions for copying, distribution and
-modification follow.
-
-                       TERMS AND CONDITIONS
-
-  0. Definitions.
-
-  "This License" refers to version 3 of the GNU Affero General Public License.
-
-  "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
-  "The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.
-
-  To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
-  A "covered work" means either the unmodified Program or a work based
-on the Program.
-
-  To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
-  To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
-  An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
-  1. Source Code.
-
-  The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.
-
-  A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
-  The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
-  The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
-  The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
-  The Corresponding Source for a work in source code form is that
-same work.
-
-  2. Basic Permissions.
-
-  All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
-  You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
-  Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.
-
-  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-  No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
-  When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
-  4. Conveying Verbatim Copies.
-
-  You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
-  You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
-  5. Conveying Modified Source Versions.
-
-  You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
-    a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.
-
-    b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".
-
-    c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.
-
-    d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.
-
-  A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
-  6. Conveying Non-Source Forms.
-
-  You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
-    a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.
-
-    b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.
-
-    c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.
-
-    d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.
-
-    e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.
-
-  A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
-  A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
-  "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
-  If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
-  The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
-  Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
-  7. Additional Terms.
-
-  "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
-  When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
-  Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
-    a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or
-
-    b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or
-
-    c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or
-
-    d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or
-
-    e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or
-
-    f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.
-
-  All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term.  If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
-  If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
-  Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
-  8. Termination.
-
-  You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
-  However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
-  Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
-  Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
-  9. Acceptance Not Required for Having Copies.
-
-  You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
-  10. Automatic Licensing of Downstream Recipients.
-
-  Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.
-
-  An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
-  You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
-  11. Patents.
-
-  A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".
-
-  A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
-  Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
-  In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
-  If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
-  If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
-  A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
-  Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
-  12. No Surrender of Others' Freedom.
-
-  If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
-  13. Remote Network Interaction; Use with the GNU General Public License.
-
-  Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software.  This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
-  Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
-combined work, and to convey the resulting work.  The terms of this
-License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
-  14. Revised Versions of this License.
-
-  The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time.  Such new versions
-will be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
-  Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU Affero General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation.  If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
-  If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
-  Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
-  15. Disclaimer of Warranty.
-
-  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
-  16. Limitation of Liability.
-
-  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
-  17. Interpretation of Sections 15 and 16.
-
-  If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
-                     END OF TERMS AND CONDITIONS
-
-            How to Apply These Terms to Your New Programs
-
-  If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
-  To do so, attach the following notices to the program.  It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    This program is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published
-    by the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    This program is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-
-    You should have received a copy of the GNU Affero General Public License
-    along with this program.  If not, see <https://www.gnu.org/licenses/>.
-
-Also add information on how to contact you by electronic and paper mail.
-
-  If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source.  For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code.  There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
-  You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-<https://www.gnu.org/licenses/>.
\ No newline at end of file
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index d8411e4fb..000000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,2 +0,0 @@
-include LICENSE README.md CHANGELOG.md
-recursive-include tests *.py
\ No newline at end of file
diff --git a/Makefile b/Makefile
deleted file mode 100644
index b1fd894fa..000000000
--- a/Makefile
+++ /dev/null
@@ -1,22 +0,0 @@
-.PHONY: help clean dev docs package test
-
-help:
-	@echo "This project assumes that an active Python virtualenv is present."
-	@echo "The following make targets are available:"
-	@echo "	 dev 	install all deps for dev env"
-	@echo "  docs	create pydocs for all relveant modules"
-	@echo "	 test	run all tests with coverage"
-
-clean:
-	rm -rf dist/*
-
-dev:
-	pip install twine
-	pip install .
-
-package:
-	python setup.py sdist
-
-test:
-	coverage run -m pytest
-	coverage html
\ No newline at end of file
diff --git a/README.md b/README.md
index 51de632e4..5e4347e95 100644
--- a/README.md
+++ b/README.md
@@ -1,76 +1,180 @@
+# magpylib
 
-<p align="center"><img align="center" src=docs/_static/images/magpylib_flag.png height="350"><p>
+[![Actions Status][actions-badge]][actions-link]
+[![Documentation Status][rtd-badge]][rtd-link]
 
----
-<div>
-<p align="center"> Builds: 
-<a href="https://anaconda.org/conda-forge/magpylib">
-<img align='center' src="https://anaconda.org/conda-forge/magpylib/badges/platforms.svg"> 
-  </a>
-<a href="https://dev.azure.com/magpylib/magpylib/_build/latest?definitionId=1&branchName=master"> <img align='center' src="https://dev.azure.com/magpylib/magpylib/_apis/build/status/magpylib.magpylib?branchName=master"> </a>
-<a href="https://circleci.com/gh/magpylib/magpylib"> <img align='center' src="https://circleci.com/gh/magpylib/magpylib.svg?style=svg"> </a>
-<a href="https://ci.appveyor.com/project/OrtnerMichael/magpylib/branch/master"> <img align='center' src="https://ci.appveyor.com/api/projects/status/0mka52e1tqnkgnx3/branch/master?svg=true"> </a>
+[![PyPI version][pypi-version]][pypi-link]
+[![Conda-Forge][conda-badge]][conda-link]
+[![PyPI platforms][pypi-platforms]][pypi-link]
 
-</p>
-
-<p align="center"> Documentation: 
-<a href="https://magpylib.readthedocs.io/en/latest/"> <img align='center' src="https://readthedocs.org/projects/magpylib/badge/?version=latest"> </a>
-<a href="https://www.gnu.org/licenses/agpl-3.0"> <img align='center' src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg"> </a>
-<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fmagpylib%2Fmagpylib?ref=badge_shield" alt="FOSSA Status"><img align='center' src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fmagpylib%2Fmagpylib.svg?type=shield"/></a>
-</p>
-
-<p align="center"> Test Coverage: 
-<a href="https://codecov.io/gh/magpylib/magpylib">
-  <img src="https://codecov.io/gh/magpylib/magpylib/branch/master/graph/badge.svg" />
-  
-</a>
-<a href="https://lgtm.com/projects/g/magpylib/magpylib/context:python"><img alt="Language grade: Python" src="https://img.shields.io/lgtm/grade/python/g/magpylib/magpylib.svg?logo=lgtm&logoWidth=18"/></a>
-</p>
+[![GitHub Discussion][github-discussions-badge]][github-discussions-link]
 
-<p align="center"> Downloads: 
-<a href="https://pypi.org/project/magpylib/">
-<img src="https://badge.fury.io/py/magpylib.svg" alt="PyPI version" height="18"></a>
-<a href="https://anaconda.org/conda-forge/magpylib"><img src="https://anaconda.org/conda-forge/magpylib/badges/version.svg" alt="Conda Cloud" height="18"></a>
-<a href="https://anaconda.org/conda-forge/magpylib"><img src="https://anaconda.org/conda-forge/magpylib/badges/installer/conda.svg" alt="Conda Cloud" height="18"></a>
-</p>
+<!-- SPHINX-START -->
 
-</div>
+<!-- prettier-ignore-start -->
+[actions-badge]:            https://github.com/magpylib/magpylib/workflows/CI/badge.svg
+[actions-link]:             https://github.com/magpylib/magpylib/actions
+[conda-badge]:              https://img.shields.io/conda/vn/conda-forge/magpylib
+[conda-link]:               https://github.com/conda-forge/magpylib-feedstock
+[github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github
+[github-discussions-link]:  https://github.com/magpylib/magpylib/discussions
+[pypi-link]:                https://pypi.org/project/magpylib/
+[pypi-platforms]:           https://img.shields.io/pypi/pyversions/magpylib
+[pypi-version]:             https://img.shields.io/pypi/v/magpylib
+[rtd-badge]:                https://readthedocs.org/projects/magpylib/badge/?version=latest
+[rtd-link]:                 https://magpylib.readthedocs.io/en/latest/?badge=latest
 
----
+<!-- prettier-ignore-end -->
 
-### What is magpylib ?
-- Python package for calculating magnetic fields of magnets, currents and
-  moments (sources).
-- Provides convenient methods to generate, geometrically manipulate, group
-  and vizualize assemblies of sources.
-- The magnetic fields are determined from underlying (semi-analytical)
-  solutions which results in fast computation times and requires little
-  computation power.
+> [!WARNING] Version 5 introduces critical breaking changes with, among others,
+> the _move to SI units_. We recommended to pin your dependencies to
+> `magpylib>=4.5<5` until you are ready to migrate to the latest version!
+> ([see details](https://github.com/magpylib/magpylib/discussions/647))
 
-<p align="center">
-    <img align='center' src=docs/_static/images/index/sourceFundamentals.png height="250">
+<p align="left"><img align="center" src=docs/_static/images/magpylib_flag.png width=35%>
 </p>
 
----
-
-### Dependencies: 
-_Python3.6+_, _Numpy_, _Matplotlib_
-
----
-
-### Docu & Install:
-
-**Please check out our [documentation](https://magpylib.readthedocs.io/en/latest) for installation, examples and detailed information!**
-
-Installing this project using pip:
-  ```
-  pip install magpylib
-  ```
-
-Installing this project locally:
-- Clone this repository to your machine.
-- In the directory, run `pip install .` in your conda terminal.
-
-
-
-
+Magpylib is an **open-source Python package** for calculating static **magnetic
+fields** of magnets, currents, and other sources. It uses **analytical
+expressions**, solutions to macroscopic magnetostatic problems, implemented in
+**vectorized** form which makes the computation **extremely fast** and leverages
+the open-source Python ecosystem for spectacular visualizations!
+
+# Installation
+
+Install from PyPI using **pip**
+
+```
+pip install magpylib
+```
+
+Install from conda forge using **conda**
+
+```
+conda install -c conda-forge magpylib
+```
+
+Magpylib supports _Python3.11+_ and relies on common scientific computation
+libraries _NumPy_, _Scipy_, _Matplotlib_ and _Plotly_. Optionally, _Pyvista_ is
+recommended as graphical backend.
+
+# Resources
+
+- Check out our **[Documentation](https://magpylib.readthedocs.io/en/stable)**
+  for detailed information about the last stable release, or the
+  **[Dev Docs](https://magpylib.readthedocs.io/en/latest)** to see the
+  unreleased development version features.
+- Please abide by our
+  **[Code of Conduct](https://github.com/magpylib/magpylib/blob/main/CODE_OF_CONDUCT.md)**.
+- Contribute through
+  **[Discussions](https://github.com/magpylib/magpylib/discussions)** and coding
+  by following the
+  **[Contribution Guide](https://github.com/magpylib/magpylib/blob/main/CONTRIBUTING.md)**.
+  The Git project **[Issues](https://github.com/magpylib/magpylib/issues)** give
+  an up-to-date list of potential enhancements and planned milestones. Propose
+  new ones.
+- A **[Youtube video](https://www.youtube.com/watch?v=LeUx6cM1vcs)**
+  introduction to Magpylib v4.0.0 within the
+  **[GSC network](https://www.internationalcollaboration.org/).**
+- An
+  **[open-access paper](https://www.sciencedirect.com/science/article/pii/S2352711020300170)**
+  from the year 2020 describes v2 of this library with most basic concepts still
+  intact in later versions.
+
+# Quickstart
+
+Here is an example on how to use Magpylib.
+
+```python
+import magpylib as magpy
+
+# Create a Cuboid magnet with sides 1,2 and 3 cm respectively, and a polarization
+# of 1000 mT pointing in x-direction.
+cube = magpy.magnet.Cuboid(
+    polarization=(1, 0, 0),  # in SI Units (T)
+    dimension=(0.01, 0.02, 0.03),  # in SI Units (m)
+)
+
+# By default, the magnet position is (0,0,0) and its orientation is the unit
+# rotation (given by a scipy rotation object), which corresponds to magnet sided
+# parallel to global coordinate axes.
+print(cube.position)  # --> [0. 0. 0.]
+print(cube.orientation.as_rotvec())  # --> [0. 0. 0.]
+
+# Manipulate object position and orientation through the respective attributes,
+# or by using the powerful `move` and `rotate` methods.
+cube.move((0, 0, -0.02))  # in SI Units (m)
+cube.rotate_from_angax(angle=45, axis="z")
+print(cube.position)  # --> [0. 0. -0.02]
+print(cube.orientation.as_rotvec(degrees=True))  # --> [0. 0. 45.]
+
+# Compute the magnetic B-field in units of T at a set of observer positions. Magpylib
+# makes use of vectorized computation. Hand over all field computation instances,
+# e.g. different observer positions, at one function call. Avoid Python loops !!!
+observers = [(0, 0, 0), (0.01, 0, 0), (0.02, 0, 0)]  # in SI Units (m)
+B = magpy.getB(cube, observers)
+print(B.round(2))  # --> [[-0.09 -0.09  0.  ]
+#                         [ 0.   -0.04  0.08]
+#                         [ 0.02 -0.01  0.03]]  # in SI Units (T)
+
+# Sensors are observer objects that can have their own position and orientation.
+# Compute the H-field in units of A/m.
+sensor = magpy.Sensor(position=(0, 0, 0))
+sensor.rotate_from_angax(angle=45, axis=(1, 1, 1))
+H = magpy.getH(cube, sensor)
+print(H.round())  # --> [-94537. -35642. -14085.]  # in SI Units (A/m)
+
+# Position and orientation attributes of Magpylib objects can be vectors of
+# multiple positions/orientations referred to as "paths". When computing the
+# magnetic field of an object with a path, it is computed at every path index.
+cube.position = [(0, 0, -0.02), (1, 0, -0.02), (2, 0, -0.02)]  # in SI Units (m)
+B = cube.getB(sensor)
+print(B.round(2))  # --> [[-0.12 -0.04 -0.02]
+#                         [ 0.   -0.    0.  ]
+#                         [ 0.   -0.    0.  ]] # in SI Units (T)
+
+# When several objects are involved and things are getting complex, make use of
+# the `show` function to view your system through Matplotlib, Plotly or Pyvista backends.
+magpy.show(cube, sensor, backend="pyvista")
+```
+
+More details and other important features are described in detail in the
+**[Documentation](https://magpylib.readthedocs.io/en/stable)**. Key features
+are:
+
+- **Collections**: Group multiple objects for common manipulation
+- **Complex shapes**: Create magnets with arbitrary shapes
+- **Graphics**: Styling options, graphic backends, animations, and 3D models
+- **CustomSource**: Integrate your own field implementation
+- **Direct interface**: Bypass the object oriented interface (max speed)
+
+# How can I cite this library ?
+
+We would be happy if you give us credit for our efforts. A valid bibtex entry
+for the
+[2020 open-access paper](https://www.sciencedirect.com/science/article/pii/S2352711020300170)
+would be
+
+```
+@article{ortner2020magpylib,
+  title={Magpylib: A free Python package for magnetic field computation},
+  author={Ortner, Michael and Bandeira, Lucas Gabriel Coliado},
+  journal={SoftwareX},
+  volume={11},
+  pages={100466},
+  year={2020},
+  publisher={Elsevier}
+}
+```
+
+A valid software citation could be
+
+```
+@software{magpylib,
+    author = {{Michael-Ortner et al.}},
+    title = {magpylib},
+    url = {https://magpylib.readthedocs.io/en/latest/},
+    version = {5.1.1},
+    date = {2023-06-25},
+}
+```
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index f1ae50caf..000000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-# appveyor.yml
----
-branches:
-  only:
-    - master
-
-environment:
-  matrix:
-
-    - PYTHON: "C:\\Python36"
-      PYTHON_VERSION: "3.6.x" # currently 3.6.5
-      PYTHON_ARCH: "32"
-
-    - PYTHON: "C:\\Python36-x64"
-      PYTHON_VERSION: "3.6.x" # currently 3.6.5
-      PYTHON_ARCH: "64"
-    
-    - PYTHON: "C:\\Python37"
-      PYTHON_VERSION: "3.7.0"
-      PYTHON_ARCH: "32"
-    
-    - PYTHON: "C:\\Python37-x64"
-      PYTHON_VERSION: "3.7.0"
-      PYTHON_ARCH: "64"
-  
-build: off
-
-install:
-  ## This install step is modified from 
-  ## https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor.yml
-  ## for getting all Python versions through.
-
-  # Install Python (from the official .msi of https://python.org) and pip when
-  # not already installed.
-  - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 }
-
-  # Prepend newly installed Python to the PATH of this build (this cannot be
-  # done from inside the powershell script as it would require to restart
-  # the parent CMD process).
-  - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
-
-  # Check that we have the expected version and architecture for Python
-  - "python --version"
-  - "python -c \"import struct; print(struct.calcsize('P') * 8)\""
-
-  # Upgrade to the latest version of pip to avoid it displaying warnings
-  # about it being out of date.
-  - "python -m pip install --upgrade pip"
-
-  # Install the build dependencies of the project. If some dependencies contain
-  # compiled extensions and are not provided as pre-built wheel packages,
-  # pip will build them from source using the MSVC compiler matching the
-  # target Python version and architecture
-  - "%CMD_IN_ENV% pip install ."
-  - "%CMD_IN_ENV% pip install pytest"
-
-test_script:
-  # Run the project tests and store results in .xml log
-  
-  - ps: |
-      # this produces nosetests.xml which is uploaded on_finish
-      &$env:PYTHON\python -m pytest
-      if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) }
-
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index 6a522ad0c..000000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-# Python package
-# Create and test a Python package on multiple Python versions.
-# Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more:
-# https://docs.microsoft.com/azure/devops/pipelines/languages/python
-
-trigger:
-- master
-- development 
-- feature/*
-
-pool:
-  vmImage: 'vs2017-win2016'
-strategy:
-  matrix:
-    Python36:
-      python.version: '3.6'
-    Python37:
-      python.version: '3.7'
-
-steps:
-- task: UsePythonVersion@0
-  inputs:
-    versionSpec: '$(python.version)'
-  displayName: 'Use Python $(python.version)'
-
-- script: |
-    python -m pip install --upgrade pip
-    pip install .
-  displayName: 'Install dependencies'
-
-- script: |
-    pip install pytest pytest-azurepipelines
-    pytest
-  displayName: 'pytest'
diff --git a/docs/Makefile b/docs/Makefile
deleted file mode 100644
index c794a6b41..000000000
--- a/docs/Makefile
+++ /dev/null
@@ -1,20 +0,0 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line.
-SPHINXOPTS    =
-SPHINXBUILD   = sphinx-build
-SOURCEDIR     = .
-BUILDDIR      = _build
-
-# Put it first so that "make" without argument is like "make help".
-help:
-	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
-	@pip install -r ./requirements.txt
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
index 57c647aea..bc95c1e35 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,59 +1,9 @@
-## About magPyLib Documentation
+## About Magpylib Documentation
 
-- Documentation is done with [Sphinx](http://www.sphinx-doc.org/en/master/) v1.8.2.
-- Sphinx configuration is [conf.py](./conf.py);
-- Docstring format is under the [Numpy Convention](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html).
-- Sphinx is configured to read Docstring information from the codebase and convert it into pages utilizing the [autodoc extension](http://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html). 
-   
-  - These generated files are created at build time and put into a folder called `_autogen`
+The Documentation is built with [Sphinx](http://www.sphinx-doc.org/en/main/) and the configuration file is [conf.py](./conf.py). Files get converted to `.html` files by Sphinx during build time. Images, web code and videos are kept in the [_static](./_static) folder.
 
-- Handwritten document pages and guides are kept in the [_pages](./_pages) folder.
-  - They use a combination of [Markdown](https://commonmark.org/help/) and [restructuredText](http://docutils.sourceforge.net/docs/ref/rst/directives.html), utilizing [recommonmark](https://github.com/rtfd/recommonmark) as interface.
-  - These documents are converted to `.html` files by Sphinx during build time.
-
-- Example code with visual output **generated during build time** are kept in the [pyplots](./pyplots) folder.
-  - This utilizes the Matplotlib's [plot directive for restructuredText](https://matplotlib.org/devel/plot_directive.html), placing the code and its graphical output when it is referenced within the directive in the documentation pages.
-
-- Images, web code and videos are kept in the [_static](./_static) folder.
-
----
-
-### Building Locally
-
-This repository is set up to be easily built on [ReadTheDocs](https://readthedocs.org/) as the codebase is updated. 
-
-##### To build locally on Linux, 
-
-1. [Install Sphinx](http://www.sphinx-doc.org/en/master/usage/installation.html) 
-2. Install the dependencies on [requirements.txt](./requirements.txt):
-    ```
-    pip install requirements.txt
-    ```
-
-
-3. Run [make](http://man7.org/linux/man-pages/man1/make.1.html) to build the documentation:
-
-    ```bash
-
-    make html
-    ```
-
-This will create a `_build` folder with an `index.html`, containing the built documentation webpage structure.
-
----
-
-##### To build locally on Windows,
-
-1. [Install Sphinx](http://www.sphinx-doc.org/en/master/usage/installation.html) 
-2. Install the dependencies on [requirements.txt](./requirements.txt):
-    ```
-    pip install -r requirements.txt
-    ```
-
-3. Build the documentation with the `.bat` script:
-
-    ```bash
-
-    make.bat html
-    ```
+### API docs
+ The docstring format is under the [NumPy Convention](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html). Sphinx is configured to read Docstring information from the codebase and convert it into pages utilizing the [autodoc extension](http://www.sphinx-doc.org/en/main/usage/extensions/autodoc.html). The generated files are created at build time and put into a folder called `_autogen`
 
+### Handwritten documents
+Handwritten pages and guides are kept in the [_pages](./_pages) folder. They are all written in [Markdown](https://www.markdownguide.org/) using [myst-parser](https://github.com/executablebooks/MyST-Parser) as interface. Some documents like in the examples folder are dynamically computed with [myst-nb](https://github.com/executablebooks/myst-nb) as jupyter notebooks. With the help of the [jupytext](https://github.com/mwouts/jupytext) library ands its jupyterlab extension, examples can be written and executed within the jupyterlab ecosystem and saved as markdown file. It is recommended to use the [jupyterlab-myst](https://github.com/executablebooks/jupyterlab-myst) extension to be able to work with the full set of myst markdown flavor within jupyterlab. When editing the docs with vscode, use the [MyST-Markdown](https://marketplace.visualstudio.com/items?itemName=ExecutableBookProject.myst-highlight) extension to visualize the rendered document.
diff --git a/docs/_pages/0_documentation.rst b/docs/_pages/0_documentation.rst
deleted file mode 100644
index b96882346..000000000
--- a/docs/_pages/0_documentation.rst
+++ /dev/null
@@ -1,567 +0,0 @@
-.. _docu:
-
-******************************
-Documentation v2.3.0b
-******************************
-
-The idea behind magpylib is to provide a simple and easy-to-use interface for computing the magnetic field of magnets, currents and moments. The computations are based on (semi-)analytical solutions found in the literature, discussed in the :ref:`physComp` section.
-
-Contents
-########
-
-* :ref:`docu-PackageStructure`
-* :ref:`docu-unitsAndIO`
-* :ref:`docu-sourceClass`
-
-  * :ref:`docu-posOrient`
-  * :ref:`docu-DimExc`
-  * :ref:`docu-GeoManip`
-
-* :ref:`docu-CalcBfield`
-
-  * :ref:`docu-vector`
-
-* :ref:`docu-collection`
-* :ref:`docu-sensor`
-* :ref:`docu-displaySystem`
-* :ref:`docu-math`
-
-
-.. _docu-PackageStructure:
-
-Package Structure
-#################
-
-The top level of magpylib contains the sub-packages  and :mod:`~magpylib.source`, :mod:`~magpylib.vector` and :mod:`~magpylib.math`, the classes :class:`magpylib.Collection` and :class:`magpylib.Sensor` as well as the function :meth:`magpylib.displaySystem`.
-
-1. The **source module** includes a set of classes that represent physical sources of the magnetic field (e.g. permanent magnets).
-
-2. The **vector module** includes functions for performance computation.
-
-3. The **math module** contains practical functions for working with angle-axis rotations and transformation to and from Euler angle representation.
-
-4. The **Collection class** is used to group sources and for common manipulation.
-
-5. The **Sensor class** represents a 3D magnetic sensor.
-
-6. The **displaySystem function** is used to create a graphical output of the system geometry.
-
-.. figure:: ../_static/images/documentation/lib_structure.png
-    :align: center
-    :alt: Library structure fig missing !!!
-    :figclass: align-center
-    :scale: 60 %
-
-    **Figure:** Outline of library structure.
-
-Check out the :ref:`genindex` and :ref:`modindex` for more details.
-
-
-.. _docu-unitsAndIO:
-
-Units and IO types
-##################
-
-In magpylib all inputs and outputs are made in the physical units of
-
-- **Millimeter** for lengths
-- **Degree** for angles
-- **Millitesla** for magnetization/remanence, magnetic moment and magnetic field,
-- **Ampere** for currents.
-
-Unless specifically state otherwise in the docstring (see vector package), **scalar input** can be of ``int`` or ``float`` type and **vector/matrix input** can be given either in the form of a ``list``, as a ``tuple`` or as a ``numpy.array``.
-
-The library output and all object attributes are either of ``numpy.float64`` or ``numpy.array64`` type.
-
-
-.. _docu-sourceClass:
-
-The Source Class
-#################
-
-This is the core class of the library. The idea is that source objects represent physical magnetic field sources in Cartesian three-dimensional space. The following source types are currently implemented,
-
-.. figure:: ../_static/images/documentation/SourceTypes.png
-  :align: center
-  :scale: 60 %
-
-  **Figure:** Source types currently available in magpylib.
-
-All source objects share various attributes and methods. The attributes characterize the source (e.g. position, orientation, dimension) while the methods can be used for geometric manipulation and for calculating the magnetic field. The figure below gives a graphical overview.
-
-.. figure:: ../_static/images/documentation/sourceVarsMethods.png
-  :align: center
-  :scale: 60 %
-
-  **Figure:** Illustration of attributes and methods of the source class objects.
-
-
-.. _docu-posOrient:
-
-Position and Orientation
-------------------------
-The most fundamental properties of a source object ``s`` are position and orientation which are represented through the attributes ``s.position`` (*arr3*), ``s.angle`` (*float*) and ``s.axis`` (*arr3*). At source initialization, if no values are specified, the source object is initialized by default with ``position=(0,0,0)``, and **init orientation** defined to be ``angle=0`` and ``axis=(0,0,1)``.
-
-Due to their different nature each source type is characterized by different attributes. However, in general the ``position`` attribute refers to the position of the geometric center of the source. The **init orientation** generally defines sources standing upright oriented along the Cartesian coordinates axes, see e.g. the following image below.
-
-An orientation of a source ``s`` given by (``angle``, ``axis`` ) refers to a rotation of ``s`` RELATIVE TO the **init orientation** about an axis specified by the ``s.axis`` vector which is anchored at ``s.position``. The angle of this rotation is given by the ``s.angle`` attribute. Mathematically, every possible orientation can be expressed by such a single angle-axis rotation. For easier use of the angle-axis rotation and transformation to Euler angles the :ref:`docu-math` provides some useful methods.
-
-.. figure:: ../_static/images/documentation/sourceOrientation.png
-  :align: center
-  :scale: 50 %
-
-  **Figure:** Illustration of the angle-axis system used to describe source orientations.
-
-
-.. _docu-DimExc:
-
-Dimension & Excitation
---------------------
-
-While position and orientation have default values, a source is defined through its geometry (e.g. cylinder) and excitation (e.g. magnetization vector) which must be initialized to provide meaning.
-
-The ``dimension`` input specifies the size of the source. However, as each source type requires different input parameters the format is always different:
-
-* ``Box.dimension`` is a 3D array of the cuboid sides, *[a,b,c]*
-* ``Cylinder.dimension`` is a 2D array of diameter and height, *[d,h]*
-* ``Sphere.dimension`` is a float describing the diameter *d*
-* ``Facet.dimension`` is a 3x3 array of the three corner vertices, *[A,B,C]*
-* ``Line.dimension`` is a Nx3 array of N subsequent vertices, *[V1,V2,V3,...]*
-* ``Circular.dimension`` is a float describing the diameter *d*
-
-.. figure:: ../_static/images/documentation/sourceGeometry.png
-  :align: center
-  :scale: 50 %
-
-  **Figure:** Illustration of information given by the dimension-attribute. The source positions (typically the geometric center) are indicated by the red dot.
-
-The excitation of a source is either the ``magnetization``, the ``current`` or the magnetic ``moment``:
-
-* Magnet sources represent homogeneously magnetized permanent magnets (other types with radial or multipole magnetization are not implemented at this point). Such excitations are given by the ``magnetization`` (*vec3*) input which is always given with respect to the **init orientation** of the magnet.
-* Current sources represent electrical line currents. Their excitation is simply the electrical current in units of Ampere defined by the ``current`` (*float*) input.
-* The moment class represents a magnetic dipole moment described by the ``moment`` (*vec3*) input.
-
-Detailed information about the attributes of each specific source type and how to initialize them can also be found in the respective class docstrings:
-:mod:`~magpylib.source.magnet.Box`, :mod:`~magpylib.source.magnet.Cylinder`, :mod:`~magpylib.source.magnet.Sphere`, :mod:`~magpylib.source.magnet.Facet`, :mod:`~magpylib.source.current.Line`, :mod:`~magpylib.source.current.Circular`, :mod:`~magpylib.source.moment.Dipole` 
-
-.. note::
-  For convenience the attributes ``magnetization``, ``current``, ``dimension`` and ``position`` are initialized through the keywords ``mag``, ``curr``, ``dim`` and ``pos``.
-
-The following code shows how to initialize a source object, a D4H5 permanent magnet cylinder with diagonal magnetization, positioned with the center in the origin, standing upright with axis in z-direction.
-
-.. code-block:: python
-
-  from magpylib.source.magnet import Cylinder
-
-  s = Cylinder( mag = [500,0,500], # The magnetization vector in mT.
-                dim = [4,5])       # dimension (diameter,height) in mm.
-                
-  # no pos, angle, axis specified so default values are used
-
-  print(s.magnetization)  # Output: [500. 0. 500.]
-  print(s.dimension)      # Output: [4. 5.]
-  print(s.position)       # Output: [0. 0. 0.]
-  print(s.angle)          # Output: 0.0
-  print(s.axis)           # Output: [0. 0. 1.]
-
-.. figure:: ../_static/images/documentation/Source_Display.JPG
-  :align: center
-  :scale: 50 %
-
-  **Figure:** Magnet geometry created by above code: A cylinder which stands upright with geometric center at the origin.
-
-
-.. _docu-GeoManip:
-
-Methods for Geometric Manipulation
-----------------------------------
-
-In most cases we want to move the source to a designated position, orient it in a desired way or change its dimension dynamically. There are several ways to achieve this:
-
-**At initialization:**
-
-When initializing the source we can set all attributes as desired. So instead of *moving one source around* one could create a new source for each parameter set of interest.
-
-**Manipulation after initialization:**
-
-We initialize the source and manipulate it afterwards as desired by
-
-1. directly setting the source attributes (e.g. ``s.position=newPosition``),
-2. or by using provided methods of manipulation.
-
-The latter is often the most practical and intuitive way. To this end the source class provides a set of methods for convenient geometric manipulation. The methods include ``setPosition`` and ``move`` for translation of the objects as well as ``setOrientation`` and ``rotate`` for rotation operations. Upon application they will simply modify the source object attributes accordingly.
-
-* ``s.setPosition(newPos)``: Moves the source to the position given by the argument vector (``s.position`` -> ``newPos``)
-* ``s.move(displacement)``: Moves the source by the argument vector. (``s.position`` -> ``s.position + displacement``) 
-* ``s.setOrientation(angle,axis)``: Sets a new source orientation given by the arguments. (``s.angle``-> ``angle``, ``s.axis`` -> ``axis``)
-* ``s.rotate(ang,ax,anchor=anch)``: Rotates the source object by the angle ``ang`` about the axis ``ax`` which passes through a position given by ``anch``. As a result, source position and orientation attributes are modified. If no value for anchor is specified, the anchor is set to the object position, which means that the object rotates about itself.
-
-The following videos show the application of the four methods for geometric manipulation.
-
-|move| |setPosition|
-
-.. |setPosition| image:: ../_static/images/documentation/setPosition.gif
-  :width: 45%
-
-.. |move| image:: ../_static/images/documentation/move.gif
-  :width: 45%
-
-|rotate| |setOrientation|
-
-.. |setOrientation| image:: ../_static/images/documentation/setOrientation.gif
-   :width: 45%
-
-.. |rotate| image:: ../_static/images/documentation/rotate.gif
-   :width: 45%
-
-The following example code shows how geometric operations are applied to source objects.
-
-.. code-block:: python
-
-  from magpylib.source.magnet import Cylinder
-
-  s = Cylinder( mag = [500,0,500], dim = [4,5])
-
-  print(s.position)       # Output: [0. 0. 0.]
-
-  s.move([1,2,3])
-  print(s.position)       # Output: [1. 2. 3.]
-
-  s.move([1,2,3])
-  print(s.position)       # Output: [2. 4. 6.]
-
-
-.. _docu-CalcBfield:
-
-Calculating the Magnetic Field
-##############################
-
-To calculate the field, magpylib uses mostly analytical expressions that can be found in the literature. References, validity and discussion of these solutions can be found in the :ref:`physComp` section. In a nutshell, the fields of the dipole and the currents are exact. The analytical magnet solutions deal with homogeneous, fixed magnetizations. For hard ferromagnets with large coercive fields like Ferrite, Neodyme and SmCo the error is typically below 2%.
-
-There are two possibilities to calculate the magnetic field of a source object ``s``:
-
-1. Using the ``s.getB(pos)`` method
-2. Using the ``magpylib.vector`` subpackage
-
-**The first method:** Each source object (or collection) ``s`` has a method ``s.getB(pos)`` which returns the magnetic field generated by ``s`` at the position ``pos``.
-
-.. code-block:: python
-
-  from magpylib.source.magnet import Cylinder
-  s = Cylinder( mag = [500,0,500], dim = [4,5])
-  print(s.getB([4,4,4]))       
-
-  # Output: [ 7.69869084 15.407166    6.40155549]
-
-
-.. _docu-vector:
-
-Using magpylib.vector
----------------------
-
-**The second method:** In most cases one will be interested to determine the field for a set of positions, or for different magnet positions and orientations. While this can manually be achieved by looping ``s.getB`` this is computationally inefficient. For performance computation the ``magpylib.vector`` subpackage contains the ``getBv`` functions that offer quick access to VECTORIZED CODE. A discussion of vectorization, SIMD and performance is presented in the :ref:`physComp` section.
-
-The ``magpylib.vector.getBv`` functions evaluate the field for *N* different sets of input parameters. These *N* parameter sets are provided to the ``getBv`` functions as arrays of size *N* for each input (e.g. an *Nx3* array for the *N* different positions):
-
-``getBv_magnet(type, MAG, DIM, POSo, POSm, [angs1,angs2,...], [AXIS1,AXIS2,...], [ANCH1,ANCH2,...])``
-
-* ``type`` is a string that specifies the magnet geometry (e.g. *'box'* or *'sphere'*).
-* ``MAG`` is an *Nx3* array of magnetization vectors.
-* ``DIM`` is an *Nx3* array of magnet dimensions.
-* ``POSo`` is an *Nx3* array of observer positions.
-* ``POSm`` is an *Nx3* array of initial (before rotation) magnet positions.
-* The inputs ``[angs1, angs2, ...]``, ``[AXIS1, AXIS2, ...]``, ``[ANCH1, ANCH2, ...]`` are a lists of *N*/*Nx3* arrays that correspond to angles, axes and anchors of rotation operations. By providing multiple list entries one can apply subsequent rotation operations. By ommitting these inputs it is assumed that no rotations are applied.
-
-As a rule of thumb, ``s.getB()`` will be faster than ``getBv`` for ~5 or less field evaluations while the vectorized code will be up to ~100 times faster for 10 or more field evaluations. To achieve this performance it is critical that one follows the vectorized code paradigm (use only numpy native) when creating the ``getBv`` inputs. This is demonstrated in the following example where the magnetic field at a fixed observer position is calculated for a magnet that moves linearly in x-direction above the observer.
-
-.. code-block:: python
-
-  import magpylib as magpy
-  import numpy as np
-
-  # vector size: we calculate the field N times with different inputs
-  N = 1000
-
-  # Constant vectors, specify dtype
-  mag  = np.array([0,0,1000.])    # magnet magnetization
-  dim  = np.array([2,2,2.])       # magnet dimension
-  poso = np.array([0,0,-4.])      # position of observer
-
-  # magnet x-positions
-  xMag = np.linspace(-10,10,N)
-
-  # magpylib classic ---------------------------
-
-  Bc = np.zeros((N,3))
-  for i,x in enumerate(xMag):
-      s = magpy.source.magnet.Box(mag,dim,[x,0,0])
-      Bc[i] = s.getB(poso)
-
-  # magpylib vector ---------------------------
-
-  # Vectorizing input using numpy native instead of python loops
-  MAG = np.tile(mag,(N,1))        
-  DIM = np.tile(dim,(N,1))        
-  POSo = np.tile(poso,(N,1))
-  POSm = np.c_[xMag,np.zeros((N,2))]
-
-  # Evaluation of the N fields using vectorized code
-  Bv = magpy.vector.getBv_magnet('box',MAG,DIM,POSo,POSm)
-
-  # result ----------------------------------- 
-  # Bc == Bv    ... up to some 1e-16
-  # Copare classic and vector computation times using e.g. time.perf_counter() 
-
-More examples of vectorized code can be found in the :ref:`examples-vector` section.
-
-.. warning::
-    The functions included in the ``magpylib.vector`` package do not check the input format. All input must be in the form of numpy arrays.
-
-
-.. _docu-collection:
-
-Collections
-###########
-
-The idea behind the top level :class:`magpylib.Collection` class is to group multiple source objects for common manipulation and evaluation of the fields. 
-
-In principle a collection ``c`` is simply a list of source objects that are collected in the attribute ``c.sources`` (*list*). Operations applied individually to the collection will be applied to all sources that are part of the collection.
-
-Collections can be constructed at initialization by simply giving the sources objects as arguments. It is possible to add single sources, lists of multiple sources and even other collection objects. All sources are simply added to the ``sources`` attribute of the target collection.
-
-With the collection kwarg ``dupWarning=True``, adding multiples of the same source will be prevented, and a warning will be displayed informing the user that a source object is already in the collection's ``source`` attribute. Adding the same object multiple times can be done by setting ``dupWarning=False``.
-
-In addition, the collection class features methods to add and remove sources for command line like manipulation. The method ``c.addSources(*sources)`` will add all sources given to it to the collection ``c``. The method ``c.removeSource(ref)`` will remove the referenced source from the collection. Here the ``ref`` argument can be either a source or an integer indicating the reference position in the collection, and it defaults to the latest added source in the collection.
-
-.. code-block:: python
-
-  import magpylib as magpy
-
-  #define some magnet objects
-  mag1 = magpy.source.magnet.Box(mag=[1,2,3],dim=[1,2,3])
-  mag2 = magpy.source.magnet.Box(mag=[1,2,3],dim=[1,2,3],pos=[5,5,5])
-  mag3 = magpy.source.magnet.Box(mag=[1,2,3],dim=[1,2,3],pos=[-5,-5,-5])
-
-  #create/manipulate collection and print source positions
-  c = magpy.Collection(mag1,mag2,mag3)
-  print([s.position for s in c.sources])
-  #OUTPUT: [array([0., 0., 0.]), array([5., 5., 5.]), array([-5., -5., -5.])]
-
-  c.removeSource(1)
-  print([s.position for s in c.sources])
-  #OUTPUT: [array([0., 0., 0.]), array([-5., -5., -5.])]
-
-  c.addSources(mag2)
-  print([s.position for s in c.sources])
-  #OUTPUT: [array([0., 0., 0.]), array([-5., -5., -5.]), array([5., 5., 5.])]
-
-All methods of geometric operation, ``setPosition``, ``move``, ``setOrientation`` and ``rotate`` are also methods of the collection class. A geometric operation applied to a collection is directly applied to each object within that collection individually. In practice this means that a whole group of magnets can be rotated about a common pivot point with a single command.
-
-For calculating the magnetic field that is generated by a whole collection the method ``getB`` is also available. The total magnetic field is simply given by the superposition of the fields of all sources.
-
-|Collection| |total Field|
-
-.. |Collection| image:: ../_static/images/documentation/collectionExample.gif
-   :width: 45%
-
-.. |total Field| image:: ../_static/images/documentation/collectionAnalysis.png
-   :width: 50%
-
-**Figure:** *Collection Example. Circular current sources are grouped into a collection to form a coil. The whole coil is then geometrically manipulated and the total magnetic field is calculated and shown in the xz-plane.*
-
-
-.. _docu-sensor:
-
-The Sensor Class
-################
-
-The ``getB`` method will always calculate the field in the underlying canonical basis. But often one is dealing with moving and tilting sensors. For this magpylib also offers a :class:`magpylib.Sensor` class. 
-
-Geometrically, a sensor object ``sens`` behaves just like a source object, having position and orientation attributes that can be set using the convenient methods ``sens.setPosition``, ``sens.move``, ``sens.setOrientation`` and ``sens.rotate``.
-
-To return the field of the source ``s`` as seen by the sensor one can use the method ``sens.getB(s)``. Here ``s`` can be a source object or a collection of sources.
-
-.. code-block:: python
-
-  import magpylib as magpy
-
-  # define sensor
-  sens = magpy.Sensor(pos=[5,0,0])
-
-  # define source
-  s = magpy.source.magnet.Sphere(mag=[123,0,0],dim=5)
-
-  # determine sensor-field
-  B1 = sens.getB(s)
-
-  # rotate sensor about itself (no anchor specified)
-  sens.rotate(90,[0,0,1])
-
-  # determine sensor-field
-  B2 = sens.getB(s)
-
-  # print fields
-  print(B1)   # output: [10.25  0.  0.]
-  print(B2)   # output: [0. -10.25  0.]
-
-
-.. _docu-displaySystem:
-
-Display System Graphically
-############################
-
-Then top level function ``displaySystem(c)`` can be used to quickly check the geometry of a source-sensor-marker assembly graphically in a 3D plot. Here ``c`` can be a source, a list of sources or a collection. ``displaySystem`` uses the matplotlib package and its limited capabilities of 3D plotting which often results in bad object overlapping. 
-
-``displaySystem(c)`` comes with several keyword arguments:
-
-* ``markers=listOfPos`` for displaying designated reference positions. By default a marker is set at the origin. By providing ``[a,b,c,'text']`` instead of just a simple position vector ``'text'`` is displayed with the marker.
-* ``suppress=True`` for suppressing the immediate figure output when the function is called. To do so it is necessary to deactivate the interactive mode by calling ``pyplot.ioff()``. With `Spyder's <https://www.spyder-ide.org/>`_ IPython *Inline* plotting, graphs made with :meth:`~magpylib.displaySystem()` can be blank if the ``suppress=True`` option is not used. Set IPython Graphics backend to *Automatic* or *Qt5* instead of *Inline* in settings/IPython console/Graphics method to address this.
-* ``direc=True`` for displaying current and magnetization directions in the figure.
-* ``subplotAx=None`` for displaying the plot on a designated figure subplot instance.
-* ``figsize=(8,8)`` for setting the size of the output graphic.
-
-The following example code shows how to use ``displaySystem()``:
-
-.. plot:: pyplots/doku/displaySys.py
-  :include-source:
-
-  **Figure:** Several magnet and sensor objects are created and manipulated. Using ``displaySystem()`` they are shown in a 3D plot together with some markers which allows one to quickly check if the system geometry is ok.
-
-
-
-.. _docu-complexMagnet:
-
-Complex Magnet Geometries
-###########################
-
-As a result of the superposition principle complex magnet shapes and inhomogeneous magnetizations can be generated by combining multiple sources. Specifically, when two magnets overlap in this region a *vector union* applies. This means that in the overlap the magnetization vector is given by the sum of the two vectors of each object.
-
-.. figure:: ../_static/images/documentation/superposition.png
-  :align: center
-  :scale: 50 %
-
-  **Figure:** Schematic of the *vector union* principle for magnetizations.
-
-Geometric addition is simply achieved by placing magnets with similar magnetization next to each other. Subtraction is realized by placing a small magnet with opposite magnetization inside a large magnet. The magnetization vectors cancel in the overlap, meaning that a small volume is cut out from a larger one. An example of a hollow cylinder is given in the examples section: :ref:`examples-complexShapes`.
-
-
-
-.. _docu-math:
-
-Math Package
-###############
-
-The math package provides several practical functions that relate angle-axis (quaternion) rotations with the Euler angle rotations.  All functions are also available in their vectorized versions for performance computation.
-
-* ``anglesFromAxis(axis)``: This function takes an arbitrary ``axis`` argument (*vec3*) and returns its orientation given by the angles ``(phi, theta)`` that are defined as in spherical coordinates. ``phi`` is the azimuth angle and ``theta`` is the polar angle.
-  
-  .. code-block:: python
-
-    import magpylib as magpy
-
-    angles = magpy.math.anglesFromAxis([1,1,0])
-    print(angles)
-    
-    # Output = [45. 90.]
-
-* ``anglesFromAxisV(AXIS)``: This is the vectorized version of ``anglesFromAxis``. It takes an *Nx3* array of axis-vectors and returns an *Nx2* array of angle pairs. Each angle pair is ``(phi,theta)`` which are azimuth and polar angle in a spherical coordinate system respectively.
-
-  .. code-block:: python
-  
-    import numpy as np
-    import magpylib as magpy
-    
-    AX = np.array([[0,0,1],[0,0,1],[1,0,0]])
-    ANGS = magpy.math.anglesFromAxisV(AX)
-    print(ANGS)                
-    
-    # Output: [[0. 0.]  [90. 90.]  [0. 90.]])
-
-* ``axisFromAngles(angles)``: This function generates an axis (*vec3*) from the angle pair ``angles=(phi,theta)``.  Here ``phi`` is the azimuth angle and ``theta`` is the polar angle of a spherical coordinate system.
-  
-  .. code-block:: python
-    
-    import magpylib as magpy
-
-    ax = magpy.math.axisFromAngles([90,90])
-    print(ax)
-    
-    # Output: [0.0  1.0  0.0]
-
-* ``axisFromAnglesV(ANGLES)``: This is the vectorized version of ``axisFromAngles``. It generates an *Nx3* array of axis vectors from the *Nx2* array of input angle pairs ``angles``. Each angle pair is ``(phi,theta)`` which are azimuth and polar angle in a spherical coordinate system respectively.
-
-  .. code-block:: python
-    
-    import magpylib as magpy
-    import numpy as np
-
-    ANGS = np.array([[0,90],[90,180],[90,0]])
-    AX = magpy.math.axisFromAnglesV(ANGS)
-    print(np.around(AX,4))
-
-    # Output: [[1.  0. 0.]  [0. 0. -1.]  [0. 0. 1.]]
-
-
-* ``randomAxis()``: Designed for Monte Carlo simulations, this function returns a random axis (*arr3*) of length 1 with equal angular distribution.
-  
-  .. code-block:: python
-
-    import magpylib as magpy
-
-    ax = magpy.math.randomAxis()
-    print(ax)
-    
-    # Output: [-0.24834468  0.96858637  0.01285925]
-
-
-* ``randomAxisV(N)``: This is the vectorized version of ``randomAxis``. It simply returns an *Nx3* array of random vectors.
-  
-  .. code-block:: python
-
-    import magpylib as magpy
-
-    AXS = magpy.math.randomAxisV(3)
-    print(AXS)
-
-    #Output: [[ 0.39480364 -0.53600779 -0.74620757]
-    #         [ 0.02974442  0.10916333  0.9935787 ]
-    #         [-0.54639126  0.76659756 -0.33731997]]
-
-
-* ``angleAxisRotation(pos, angle, axis, anchor=[0,0,0])``: This function applies an angle-axis rotation.  The position vector ``pos`` (*vec3*) is rotated by the angle ``angle`` (*float*) about an axis defined by the ``axis`` vector (*vec3*) which passes through the ``anchor`` position (*vec3*). The anchor argument is optional and is set to ``anchor=[0,0,0]`` if ommitted.
-
-  .. code-block:: python
-
-    import magpylib as magpy
-    
-    pos = [1,1,0]
-    angle = -90
-    axis = [0,0,1]
-    anchor = [1,0,0]
-    
-    posNew = magpy.math.angleAxisRotation(pos,angle,axis,anchor)
-    print(posNew)
-    
-    # Output = [2. 0. 0.]
-
-
-* ``angleAxisRotationV(POS, ANG, AXS, ANCH)``: This is the vectorized version of ``angleAxisRotation``. Each entry of ``POS`` (*arrNx3*) is rotated according to the angles ``ANG`` (*arrN*), about the axis vectors ``AXS`` (*arrNx3*) which pass throught the anchors ``ANCH`` (*arrNx3*) where *N* refers to the length of the input vectors.
-
-  .. code-block:: python
-
-    import magpylib as magpy
-    import numpy as np
-
-    POS = np.array([[1,0,0]]*5) # avoid this slow Python loop for performance conputation
-    ANG = np.linspace(0,180,5)
-    AXS = np.array([[0,0,1]]*5) # avoid this slow Python loop for performance conputation
-    ANCH = np.zeros((5,3))
-
-    POSnew = magpy.math.angleAxisRotationV(POS,ANG,AXS,ANCH)
-    print(np.around(POSnew,4))
-
-    # Output: [[ 1.      0.      0.    ]
-    #          [ 0.7071  0.7071  0.    ]
-    #          [ 0.      1.      0.    ]
-    #          [-0.7071  0.7071  0.    ]
-    #          [-1.      0.      0.    ]]
\ No newline at end of file
diff --git a/docs/_pages/1_how2install.rst b/docs/_pages/1_how2install.rst
deleted file mode 100644
index c3b2a7269..000000000
--- a/docs/_pages/1_how2install.rst
+++ /dev/null
@@ -1,106 +0,0 @@
-.. _installation:
-
-*************************
-Installation
-*************************
-
-.. warning::
-    magpylib works only with Python 3.6 or later !
-    
-    **Dependencies:**
-        - numpy
-        - matplotlib
-    The latest versions will be installed automatically with magpylib.
-
-
-Content 
-#######
-
-* :ref:`install-pip`
-* :ref:`install-win`
-* :ref:`install-linux`
-* :ref:`install-dl`
-
-
-
-.. _install-pip:
-
-Install with pip
-################
-
-The quickest installation on any platform is through pip.
-
-.. code-block:: console
-    
-    pip install magpylib
-
-If you are unfamiliar with pip, please follow the detailed guides below:
-
-
-
-.. _install-win:
-
-Windows
-#######
-
-Anaconda 3 Install
-------------------
-
-If you have little experience with Python we recommand using `Anaconda <https://www.anaconda.com>`_.
-
-1. Download & install Anaconda3
-2. Start Anaconda Navigator 
-3. On the interface, go to `Environments` and choose the environment you wish to install magpylib in. For this example, we will use the base environment: 
-
-    .. image:: ../_static/images/install_guide/anaconda0.png
-   
-4. Click the arrow, and open the conda terminal 
-
-    .. image:: ../_static/images/install_guide/anaconda1.png
-
-5. Input the following to install from conda-forge:
-
-    .. code-block:: console
-
-        conda install -c conda-forge magpylib 
-
-6. Dont forget to select the proper environment in your IDE.
-
-    .. image:: ../_static/images/install_guide/anaconda2.png
-
-
-Clean Python 3 Install
-----------------------
-
-If you want to have a custom environment without using conda, you may simply install the library with pip. A simple guide for installation and functionality of pip is found `here <https://projects.raspberrypi.org/en/projects/using-pip-on-windows/5>`_
-
-
-
-.. _install-linux:
-
-Linux
-#######
-
-Recommended: use Anaconda environment. Simply download Anaconda3 and follow installation steps as under Windows.
-
-Terminal Python 3 Install
---------------------------
-
-1. Install Python3.
-2. Open your Terminal and install with
-
-    .. code-block:: console
-
-        pip install magpylib
-
-
-.. _install-dl:
-
-Download Sites
-#################
-
-Currently magpylib is hosted at:
-
-* `Conda Cloud <https://anaconda.org/conda-forge/magpylib>`_ 
-* `Python Package Index <https://pypi.org/project/magpylib/>`_
-* `GitHub repository <https://github.com/magpylib/magpylib>`_
\ No newline at end of file
diff --git a/docs/_pages/2_guideExamples.rst b/docs/_pages/2_guideExamples.rst
deleted file mode 100644
index 8f3e50519..000000000
--- a/docs/_pages/2_guideExamples.rst
+++ /dev/null
@@ -1,158 +0,0 @@
-.. _examples:
-
-*******************************
-Example Codes
-*******************************
-
-This section includes a few code examples that show how the library can be used and what i can be used for. A detailed technical library documentation can be found in the :ref:`docu`.
-
-
-Contents
-########
-
-* :ref:`examples-simplest`
-* :ref:`examples-basic`
-* :ref:`examples-sourceObjects`
-* :ref:`examples-motionBasics`
-* :ref:`examples-joystick`
-* :ref:`examples-complexShapes`
-* :ref:`examples-vector`
-
-
-
-.. _examples-simplest:
-
-Simplest Example
-#################
-
-The simplest possible example - calculate the B-field of a cylinder with 3 lines of code.
-
-.. code-block:: python
-
-    from magpylib.source.magnet import Cylinder
-    s = Cylinder( mag = [0,0,350], dim = [4,5])
-    print(s.getB([4,4,4]))       
-
-    # Output: [ 5.08641867  5.08641867 -0.60532983]
-
-
-
-.. _examples-basic:
-
-Basic Functionality: The Field of a Collection
-###############################################
-
-In this example the basic functionality is outlined. Two magnet objects are created and geometrically manipulated. The system geometry is then displayed using the ``displaySystem`` function. Finally, the field is calculated on a grid and displayed in the xz-plane.
-
-.. plot:: pyplots/examples/01_SimpleCollection.py
-    :include-source:
-
-:download:`01_SimpleCollection.py <../pyplots/examples/01_SimpleCollection.py>`
-
-
-
-.. _examples-sourceObjects:
-
-The Source Objects and their Fields
-###################################
-
-In this example we define all currently implemented source objects and display their fields. Notice that the respective magnetization vectors are chosen arbitrarily.
-
-.. plot:: pyplots/examples/01b_AllSources.py
-   :include-source:
-
-:download:`01b_AllSources.py <../pyplots/examples/01b_AllSources.py>`
-
-
-
-.. _examples-motionBasics:
-
-Translation, Orientation and Rotation Basics
-#############################################
-
-Translation of magnets can be realized in three ways, using the methods ``move`` and ``setPosition``, or by directly setting the object ``position`` attribute.
-
-.. plot:: pyplots/examples/00a_Trans.py
-   :include-source:
-
-:download:`00a_Trans.py <../pyplots/examples/00a_Trans.py>`
-
-
-Initialize magnets with different orientations defined by classical Euler angle rotations about the three Cartesian axes. Notice that the magnetization direction is fixed with respect to the **init orientation** of the magnet and will rotate together with the magnet.
-
-.. plot:: pyplots/examples/00b_OrientRot1.py
-   :include-source:
-
-:download:`00b_OrientRot1.py <../pyplots/examples/00b_OrientRot1.py>`
-
-
-The following example shows functionality beyond Euler angle rotation. This means rotation about an arbitrary axis of choice, here ``(1,-1,1)``. The upper three boxes are initialized with different orientations. The lower three boxes are all initialized with **init orientation** and are then rotated (about themselves) to achieve the same result as above.
-
-.. plot:: pyplots/examples/00c_OrientRot2.py
-   :include-source:
-
-:download:`00c_OrientRot2.py <../pyplots/examples/00c_OrientRot2.py>`
-
-
-The following example shows rotations with designated anchor-axis combinations. Here we distinguish between pivot points (the closest point on the rotation axis to the magnet) and anchor points which are simply required to define an axis in 3D space (together with a direction).
-
-.. plot:: pyplots/examples/00d_OrientRot3.py
-   :include-source:
-
-:download:`00d_OrientRot3.py <../pyplots/examples/00d_OrientRot3.py>`
-
-
-Collections can be manipulated using the previous logic as well. Notice how objects can be grouped into collections and sub-collections for common manipulation. For rotations keep in mind that if an anchor is not provided, all objects will rotate relative to their own center.
-
-.. plot:: pyplots/examples/00e_ColTransRot.py
-   :include-source:
-
-:download:`00e_ColTransRot.py <../pyplots/examples/00e_ColTransRot.py>`
-
-
-
-.. _examples-joystick:
-
-Magnet Motion: Simulating a Magnetic Joystick
-##############################################
-
-In this example a joystick is simulated. A magnetic joystick is realized by a rod that can tilt freely (two degrees of freedom) about a center of tilt. The upper part of the rod is the joystick handle. At the bottom of the rod a cylindrical magnet (``dim=(D,H)``) with axial magnetization ```mag=[0,0,M0]`` is fixed. The magnet lies at a distance ``d`` below the center of tilt. The system is constructed such that, when the joystick is in the center position a sensor lies at distance ``gap`` below the magnet and in the origin of a Cartesian coordinate system. The magnet thus moves with the joystick above the fixed sensor.
-
-In the following program the magnetic field is calculated for all degrees of freedom. Different tilt angles are set by rotation about the center of tilt by the angle ``th`` (different colors). Then the tilt direction is varied from 0 to 360 degrees by simulating the magnet motion as rotation about the z-axis, see also the following sketch.
-
-.. image:: ../_static/images/examples/JoystickExample1.JPG
-   :align: center
-   :scale: 50 %
-
-.. plot:: pyplots/examples/02_MagnetMotion.py
-   :include-source:
-
-:download:`02_MagnetMotion.py <../pyplots/examples/02_MagnetMotion.py>`
-
-
-
-.. _examples-complexShapes:
-
-Complex Magnet Shapes: Hollow Cylinder
-###########################################
-
-The superposition principle allows us to calculate complex magnet shapes by *addition* and *subtraction* operations. An example application for this is the field of an axially magnetized hollow cylinder. The hollow part is cut out from the outer cylinder by placing a second, smaller cylinder inside with opposite magnetization. Unfortunately the ``displaySystem`` method cannot properly display such objects intersecting with each other.
-
-.. plot:: pyplots/examples/04_ComplexShape.py
-   :include-source:
-
-:download:`04_ComplexShape.py <../pyplots/examples/04_ComplexShape.py>`
-
-
-
-.. _examples-vector:
-
-Vectorized Code Example
-######################################
-
-In this example a magnet is tilted above a sensor just like in a 1D-joystick system. The magnetic field is computed using vectorized code, taking care to create the ``getBv`` input using numpy native methods only. 
-
-.. plot:: pyplots/examples/05_VectorJoystick1d.py
-   :include-source:
-
-:download:`05_VectorJoystick1d.py <../pyplots/examples/05_VectorJoystick1d.py>`
\ No newline at end of file
diff --git a/docs/_pages/3_MATLAB.rst b/docs/_pages/3_MATLAB.rst
deleted file mode 100644
index 8dacb6abd..000000000
--- a/docs/_pages/3_MATLAB.rst
+++ /dev/null
@@ -1,64 +0,0 @@
-.. _matlab:
-
-******************
-MATLAB Integration
-******************
-
-.. note::
-
-   MATLAB does not support Tkinter, which disables matplotlib. This means that :meth:`~magpylib.Collection.displaySystem()` will not generate a display and might interrupt the program.
-
-
-Setting Python Interpreter
-###########################
-
-As of version R2015b, MATLAB allows you to call libraries from other programming languages, including Python, which enables users to run magpylib from the MATLAB interface. The following guide intends to provide a digest of the `Official MATLAB documentation <https://www.mathworks.com/help/matlab/call-python-libraries.html>`_ with a focus on utilizing this interface with magpylib.
-
-Running ``>>> pyversion`` following line in the MATLAB console tells you which Python environment (user space interpreter) is connected to your MATLAB interface.
-
-If magpylib is already installed in this environment you can directly call it, as shown in the `Example`_ below. If not please follow the :ref:`installation` instructions and install magpylib.
-
-If you choose to install magpylib in a different environment than the one that is currently connected to your MATLAB interpreter, use the following command in the MATLAB console to connect the new environment instead (choose correct path pointing at your Python interpreter).
-
-.. code-block:: matlab
-    
-    >>> pyversion C:\Users\...\AppData\Local\Continuum\anaconda3\envs\magpy\python.exe
-
-
-Example
-############
-
-The following MATLAB 2019 script showcases most functionalities.
-
-.. code-block:: matlab
-
-    %%%%%%%%%%%%%%%%%% magpytest.m %%%%%%%%%%%%%%
-    %% Showcase Python + MATLAB Interoperability.    
-    %% Define and calculate the field of a 
-    %% Cuboid magnet inside a Collection.
-    %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
-
-    %% Import the library
-    py.importlib.import_module("magpylib")
-
-    %% Define Python types for input
-    vec3 = py.list({1,2,3})
-    scalar = py.int(90)
-
-    %% Define input
-    mag = vec3
-    dim = vec3
-    angle = scalar
-    sensorPos = vec3
-
-    %% Execute Python
-    % 2 positional and 1 keyword argument in Box
-    box = py.magpylib.source.magnet.Box(mag,dim,pyargs('angle',angle))
-    col = py.magpylib.Collection(box)
-    pythonResult = col.getB(sensorPos)
-
-    %% Convert Python Result to MATLAB data format
-    matlabResult = double(pythonResult) 
-
-.. note::
-    With old versions of Matlab the *double(pythonResult)* type conversion might give an error message.
\ No newline at end of file
diff --git a/docs/_pages/4_contguide.rst b/docs/_pages/4_contguide.rst
deleted file mode 100644
index 0e152a0be..000000000
--- a/docs/_pages/4_contguide.rst
+++ /dev/null
@@ -1,51 +0,0 @@
-*********************************
-Credits, Contribution & Citation
-*********************************
-
-
-
-Maintainers & Contact
-#####################
-
-**Michael Ortner** - Concept, Physics and Overseeing
-
-* michael.ortner@silicon-austria.com
-* Silicon Austria Labs, Sensors division, 9500 Villach, Austria
-
-**Lucas Gabriel Coliado Bandeira** - software engineering
-
-* lucascoliado@hotmail.com
-
-
-
-Credits
-########
-
-We want to thank a lot of ppl who have helped to realize and advance this project over the years. The project was supported by CTR-AG and is now supported by the `Silicon Austria Labs <https://silicon-austria-labs.com/>`_ public research center.
-
-
-
-Contributions
-#############
-
-We welcome any feedback (Bug reports, feature requests, comments, really anything 😃) via email `magpylib@gmail.com <mailto:magpylib@gmail.com>`_ or through `gitHub <https://github.com/magpylib/magpylib/issues>`_ channels.
-
-
-
-Citation
-########
-
-We are thankful for any reference and citation through the `original publication <https://authors.elsevier.com/sd/article/S2352711020300170>`_.
-
-A valid bibtex entry would be
-
-.. code-block:: latex
-
-    @Article{magpylib2020,
-    title = {Magpylib: A free Python package for magnetic field computation},
-    author  ={Ortner, Michael and Coliado Bandeira, Lucas Gabriel}, 
-    year    = {2020},
-    journal = {SoftwareX},
-    publisher = {Elsevier},
-    doi = {10.1016/j.softx.2020.100466}
-    }
\ No newline at end of file
diff --git a/docs/_pages/9_physics.rst b/docs/_pages/9_physics.rst
deleted file mode 100644
index dad7f9ab8..000000000
--- a/docs/_pages/9_physics.rst
+++ /dev/null
@@ -1,40 +0,0 @@
-.. _physComp:
-
-***************************
-[WIP] Physics & Computation
-***************************
-
-The analytical solutions
-########################
-
-Details about how they are set up
-Formulas and expressions used in magpylib, references to literature
-
-Hard and soft magnetic materials
---------------------------------
-We cannot do soft, theres a lot of reasons we we dont need to
-
-Demagnetization
----------------
-
-Solution accuracy, analytical modeling of demagnetization and interaction
-
-multiple sources, no interaction
-
-Limits and Convergence
-######################
-
-Convergence of diametral Cylinder
-No. iterations
-
-Computation
-###########
-
-SIMD
-
-vectorized code
-
-performance tests
-
-parallelization
-
diff --git a/docs/_pages/API_reference.md b/docs/_pages/API_reference.md
new file mode 100644
index 000000000..b44c5a2bc
--- /dev/null
+++ b/docs/_pages/API_reference.md
@@ -0,0 +1,10 @@
+(docu-APIref)=
+# API reference
+
+The API reference includes all Magpylib docstrings.
+
+```{toctree}
+:maxdepth: 2
+
+../../_autogen/magpylib.rst
+```
diff --git a/docs/_pages/changelog_.md b/docs/_pages/changelog_.md
new file mode 100644
index 000000000..33ac9e8e5
--- /dev/null
+++ b/docs/_pages/changelog_.md
@@ -0,0 +1,12 @@
+
+
+(changelog)=
+# Changelog
+
+The changelog provides a compressed history of the Magpylib development since its publication in 2019.
+
+```{include} ../../CHANGELOG.md
+:relative-docs: docs/
+:relative-images:
+:start-line: 2
+```
diff --git a/docs/_pages/contributing/cont_code_of_conduct.md b/docs/_pages/contributing/cont_code_of_conduct.md
new file mode 100644
index 000000000..1dbcfc576
--- /dev/null
+++ b/docs/_pages/contributing/cont_code_of_conduct.md
@@ -0,0 +1,10 @@
+(code-of-conduct)=
+# Code of Conduct
+
+
+
+```{include} ../../../CODE_OF_CONDUCT.md
+:relative-docs: docs/
+:relative-images:
+:start-line: 2
+```
diff --git a/docs/_pages/contributing/cont_contributing.md b/docs/_pages/contributing/cont_contributing.md
new file mode 100644
index 000000000..ad09b1627
--- /dev/null
+++ b/docs/_pages/contributing/cont_contributing.md
@@ -0,0 +1,10 @@
+(contributing)=
+# Contribution Guide
+
+
+
+```{include} ../../../CONTRIBUTING.md
+:relative-docs: docs/
+:relative-images:
+:start-line: 2
+```
diff --git a/docs/_pages/contributing/cont_index.md b/docs/_pages/contributing/cont_index.md
new file mode 100644
index 000000000..3feaf1907
--- /dev/null
+++ b/docs/_pages/contributing/cont_index.md
@@ -0,0 +1,11 @@
+# Contributing
+
+Magpylib is a free-of-use open-source project aiming to help researchers and engineers with magnetic field computation. Your participation is most welcome!
+
+```{toctree}
+:maxdepth: 2
+
+cont_contributing.md
+cont_code_of_conduct.md
+cont_license.md
+```
diff --git a/docs/_pages/contributing/cont_license.md b/docs/_pages/contributing/cont_license.md
new file mode 100644
index 000000000..fa4c916db
--- /dev/null
+++ b/docs/_pages/contributing/cont_license.md
@@ -0,0 +1,19 @@
+(license)=
+# License
+
+
+
+## Overview
+
+Magpylib is published under the open source [FreeBSD](https://www.freebsd.org/copyright/freebsd-license/) license.
+
+- *Permissions:* Commercial use, Modification, Distribution, Private use
+- *Limitations:* Liability, Warranty
+- *Conditions:* License and copyright notice
+
+## License Text
+
+```{include} ../../../LICENSE
+:relative-docs: docs/
+:relative-images:
+```
diff --git a/docs/_pages/user_guide/docs/docs_classes.md b/docs/_pages/user_guide/docs/docs_classes.md
new file mode 100644
index 000000000..4c3839971
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_classes.md
@@ -0,0 +1,407 @@
+(docs-classes)=
+# The Magpylib Classes
+
+In Magpylib's [object oriented interface](docs-fieldcomp-oo) magnetic field **sources** (generate the field) and **observers** (read the field) are created as Python objects with various defining attributes and methods.
+
+## Base properties
+
+The following basic properties are shared by all Magpylib classes:
+
+* The <span style="color: orange">**position**</span> and <span style="color: orange">**orientation**</span> attributes describe the object placement in the global coordinate system.
+
+* The <span style="color: orange">**move()**</span> and <span style="color: orange">**rotate()**</span> methods enable relative object positioning.
+
+* The <span style="color: orange">**reset_path()**</span> method sets position and orientation to default values.
+
+* The <span style="color: orange">**barycenter**</span> property returns the object barycenter (often the same as position).
+
+See {ref}`docs-position` for more information on these features.
+
+
+* The <span style="color: orange">**style**</span> attribute includes all settings for graphical object representation.
+
+* The <span style="color: orange">**show()**</span> method gives quick access to the graphical representation.
+
+See {ref}`guide-graphics` for more information on graphic output, default styles and customization possibilities.
+
+* The <span style="color: orange">**getB()**</span>, <span style="color: orange">**getH()**</span>, <span style="color: orange">**getJ()**</span> and <span style="color: orange">**getM()**</span> methods give quick access to field computation.
+
+See {ref}`docs-fieldcomp` for more information.
+
+
+* The <span style="color: orange">**parent**</span> attribute references a [Collection](guide-docs-classes-collections) that the object is part of.
+
+* The <span style="color: orange">**copy()**</span> method creates a clone of any object where selected properties, given by kwargs, are modified.
+
+* The <span style="color: orange">**describe()**</span> method provides a brief description of the object and returns the unique object id.
+
+
+---------------------------------------------
+
+
+## Local and global coordinates
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+Magpylib objects span a local reference frame, and all object properties are defined within this frame, for example the vertices of a `Tetrahedron` magnet. The position and orientation attributes describe how the local frame lies within the global coordinates. The two frames coincide by default, when `position=(0,0,0)` and `orientation=None` (=unit rotation). The `position` and `orientation` attributes are described in detail in {ref}`docs-position`.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_global_local.png)
+:::
+::::
+
+
+---------------------------------------------
+
+
+(docu-magnet-classes)=
+## Magnet classes
+
+All magnets are sources. They have the <span style="color: orange">**polarization**</span> attribute which is of the format $\vec{J}=(J_x, J_y, J_z)$ and denotes a homogeneous magnetic polarization vector in the local object coordinates in units of T. Alternatively, the magnetization vector can be set via the  <span style="color: orange">**magnetization**</span> attribute of the format $\vec{M}=(M_x, M_y, M_z)$. These two parameters are codependent and Magpylib ensures that they stay in sync via the relation $\vec{J}=\mu_0\cdot\vec{M}$. Information on how this is related to material properties from data sheets is found in {ref}`examples-tutorial-modeling-magnets`.
+
+
+### Cuboid
+```python
+magpylib.magnet.Cuboid(
+    position, orientation, dimension, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Cuboid` objects represent magnets with cuboid shape. The <span style="color: orange">**dimension**</span> attribute has the format $(a,b,c)$ and denotes the sides of the cuboid units of meter. The center of the cuboid lies in the origin of the local coordinates, and the sides are parallel to the coordinate axes.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_cuboid.png)
+:::
+::::
+
+
+### Cylinder
+```python
+magpylib.magnet.Cylinder(
+    position, orientation, dimension, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Cylinder` objects represent magnets with cylindrical shape. The <span style="color: orange">**dimension**</span> attribute has the format $(d,h)$ and denotes diameter and height of the cylinder in units of meter. The center of the cylinder lies in the origin of the local coordinates, and the cylinder axis coincides with the z-axis.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_cylinder.png)
+:::
+::::
+
+
+### CylinderSegment
+```python
+magpylib.magnet.CylinderSegment(
+    position, orientation, dimension, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`CylinderSegment` objects represent magnets with the shape of a cylindrical ring section. The <span style="color: orange">**dimension**</span> attribute has the format $(r_1,r_2,h,\varphi_1,\varphi_2)$ and denotes inner radius, outer radius and height in units of meter, and the two section angles $\varphi_1<\varphi_2$ in °. The center of the full cylinder lies in the origin of the local coordinates, and the cylinder axis coincides with the z-axis.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_cylindersegment.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** When the cylinder section angles span 360°, then the much faster `Cylinder` methods are used for the field computation.
+:::
+::::
+
+
+### Sphere
+```python
+magpylib.magnet.Sphere(
+    position, orientation, diameter, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Sphere` objects represent magnets of spherical shape. The <span style="color: orange">**diameter**</span> attribute is the sphere diameter $d$ in units of meter. The center of the sphere lies in the origin of the local coordinates.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_sphere.png)
+:::
+::::
+
+
+### Tetrahedron
+```python
+magpylib.magnet.Tetrahedron(
+    position, orientation, vertices, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Tetrahedron` objects represent magnets of tetrahedral shape. The <span style="color: orange">**vertices**</span> attribute stores the four corner points $(\vec{P}_1, \vec{P}_2, \vec{P}_3, \vec{P}_4)$ in the local object coordinates in units of m.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_tetra.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** The `Tetrahedron` field is computed from four `Triangle` fields.
+:::
+::::
+
+(docu-magpylib-api-trimesh)=
+
+### TriangularMesh
+```python
+magpylib.magnet.TriangularMesh(
+    position,
+    orientation,
+    vertices,
+    faces,
+    polarization,
+    magnetization,
+    check_open,
+    check_disconnected,
+    check_selfintersecting,
+    reorient_faces,
+    style,
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`TriangularMesh` objects represent magnets with surface given by a triangular mesh. The mesh is defined by the <span style="color: orange">**vertices**</span> attribute, an array of all unique corner points $(\vec{P}_1, \vec{P}_2, ...)$ in units of meter, and the <span style="color: orange">**faces**</span> attribute, which is an array of index-triplets that define individual faces $(\vec{F}_1, \vec{F}_2, ...)$. The property <span style="color: orange">**mesh**</span> returns an array of all faces as point-triples $[(\vec{P}_1^1, \vec{P}_2^1, \vec{P}_3^1), (\vec{P}_1^2, \vec{P}_2^2, \vec{P}_3^2), ...]$.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_trimesh.png)
+:::
+:::{grid-item}
+:columns: 12
+At initialization the mesh integrity is automatically checked, and all faces are reoriented to point outwards. These actions are controlled via the kwargs
+* <span style="color: orange">**check_open**</span>
+* <span style="color: orange">**check_disconnected**</span>
+* <span style="color: orange">**check_selfintersecting**</span>
+* <span style="color: orange">**reorient_faces**</span>
+
+which are all by default set to `"warn"`. Options are `"skip"` (don't perform check), `"ignore"` (ignore if check fails), `"warn"` (warn if check fails), `"raise"` (raise error if check fails).
+
+Results of the checks are stored in the following object attributes
+* <span style="color: orange">**status_open**</span> can be `True`, `False` or `None` (unchecked)
+* <span style="color: orange">**status_open_data**</span> contains an array of open edges
+* <span style="color: orange">**status_disconnected**</span> can be `True`, `False` or `None` (unchecked)
+* <span style="color: orange">**status_disconnected_data**</span> contains an array of mesh parts
+* <span style="color: orange">**status_selfintersecting**</span> can be `True`, `None` or `None` (unchecked)
+* <span style="color: orange">**status_selfintersecting_data**</span> contains an array of self-intersecting faces
+* <span style="color: orange">**status_reoriented**</span> can be `True` or `False`
+
+The checks can also be performed after initialization using the methods
+* <span style="color: orange">**check_open()**</span>
+* <span style="color: orange">**check_disconnected()**</span>
+* <span style="color: orange">**check_selfintersecting()**</span>
+* <span style="color: orange">**reorient_faces()**</span>
+
+The following class methods enable easy mesh creating and mesh loading.
+
+* <span style="color: orange">**TriangularMesh.from_mesh()**</span> generates a `TriangularMesh` objects from the input <span style="color: orange">**mesh**</span>, which is an array in the mesh format $[(\vec{P}_1^1, \vec{P}_2^1, \vec{P}_3^1), (\vec{P}_1^2, \vec{P}_2^2, \vec{P}_3^2), ...]$.
+* <span style="color: orange">**TriangularMesh.from_ConvexHull()**</span> generates a `TriangularMesh` object from the input <span style="color: orange">**points**</span>, which is an array of positions $(\vec{P}_1, \vec{P}_2, \vec{P}_3, ...)$ from which the convex Hull is computed via the [Scipy ConvexHull](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.ConvexHull.html) implementation.
+* <span style="color: orange">**TriangularMesh.from_triangles()**</span> generates a `TriangularMesh` object from the input <span style="color: orange">**triangles**</span>, which is a list or a `Collection` of `Triangle` objects.
+* <span style="color: orange">**TriangularMesh.from_pyvista()**</span> generates a `TriangularMesh` object from the input <span style="color: orange">**polydata**</span>, which is a [Pyvista PolyData](https://docs.pyvista.org/version/stable/api/core/_autosummary/pyvista.PolyData.html) object.
+
+The method <span style="color: orange">**to_TriangleCollection()**</span> transforms a `TriangularMesh` object into a `Collection` of `Triangle` objects.
+
+**Info:** While the checks may be disabled, the field computation guarantees correct results only if the mesh is closed, connected, not self-intersecting and all faces are oriented outwards. Examples of working with the `TriangularMesh` class are found in {ref}`examples-shapes-triangle` and in {ref}`examples-shapes-pyvista`.
+:::
+::::
+
+
+---------------------------------------------
+
+
+## Current classes
+
+All currents are sources. Current objects have the <span style="color: orange">**current**</span> attribute which is a scalar that denotes the electrical current in units of ampere.
+
+### Circle
+```python
+magpylib.current.Circle(position, orientation, diameter, current, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Circle` objects represent circular line current loops. The <span style="color: orange">**diameter**</span> attribute is the loop diameter $d$ in units of meter. The loop lies in the xy-plane with it's center in the origin of the local coordinates.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_loop.png)
+:::
+::::
+
+### Polyline
+```python
+magpylib.current.Polyline(position, orientation, vertices, current, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Polyline` objects represent line current segments where the electric current flows in straight lines from vertex to vertex. The <span style="color: orange">**vertices**</span> attribute is a vector of all vertices $(\vec{P}_1, \vec{P}_2, ...)$ given in the local coordinates in units of meter.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_line.png)
+:::
+::::
+
+---------------------------------------------
+
+## Miscellaneous classes
+
+There are classes listed hereon that function as sources, but they do not represent physical magnets or current distributions.
+
+
+### Dipole
+```python
+magpylib.misc.Dipole(position, orientation, moment, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Dipole` objects represent magnetic dipole moments with the <span style="color: orange">**moment**</span> attribute that describes the magnetic dipole moment $\vec{m}=(m_x,m_y,m_z)$ in SI-units of Am², which lies in the origin of the local coordinates.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_dipole.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** The total dipole moment of a homogeneous magnet with body volume $V$ is given by $\vec{m}=\vec{M}\cdot V$.
+:::
+::::
+
+
+### Triangle
+```python
+magpylib.misc.Triangle(
+    position, orientation, vertices, polarization, magnetization, style
+)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Triangle` objects represent triangular surfaces with homogeneous charge density given by the projection of the polarization or magnetization vector onto the surface normal. The attributes <span style="color: orange">**polarization**</span> and <span style="color: orange">**magnetization**</span> are treated similar as by the {ref}`docu-magnet-classes`. The <span style="color: orange">**vertices**</span> attribute is a set of the three triangle corners $(\vec{P}_1, \vec{P}_2, \vec{P}_3)$ in units of meter in the local coordinates.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_triangle.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** When multiple Triangles with similar magnetization/polarization vectors form a closed surface, and all their orientations (right-hand-rule) point outwards, their total H-field is equivalent to the field of a homogeneous magnet of the same shape. In this case, the B-field is only correct on the outside of the body. On the inside the polarization must be added to the field. This is demonstrated in the tutorial {ref}`examples-shapes-triangle`.
+:::
+::::
+
+(guide-docs-classes-custom-source)=
+### CustomSource
+```python
+magpylib.misc.CustomSource(field_func, position, orientation, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+The `CustomSource` class is used to create user defined sources provided with with custom field computation functions. The argument <span style="color: orange">**field_func**</span> takes a function that is then automatically called for the field computation. This custom field function is treated like a [core function](docs-field-core). It must have the positional arguments `field` with values `"B"` or `"H"`, and `observers` (must accept array with shape (n,3)) and return the B-field and the H-field with a similar shape.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_custom.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** A tutorial {ref}`examples-tutorial-custom` is found in the examples.
+:::
+::::
+
+
+---------------------------------------------
+
+
+## Sensor
+```python
+magpylib.Sensor(position, orientation, pixel, handedness, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+`Sensor` objects represent observers of the magnetic field and can be used as Magpylib `observers` input for magnetic field computation. The <span style="color: orange">**pixel**</span> attribute is an array of positions $(\vec{P}_1, \vec{P}_2, ...)$ provided in units of meter in the local sensor coordinates. A sensor returns the magnetic field at these pixel positions. By default `pixel=(0,0,0)` and the sensor simply returns the field at it's position. The <span style="color: orange">**handedness**</span> attribute can be `"left"` or `"right"` (default) to set a left- or right-handed sensor coordinate system for the field computation.
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_sensor.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** Sensors can have their own position and orientation and enable easy relative positioning between sources and observers. The field is always computed in the reference frame of the sensor, which might itself be moving in the global coordinate system. Magpylib sensors can be understood as perfect magnetic field sensors with infinitesimally sensitive elements. An example how to use sensors is given in {ref}`examples-tutorial-field-computation-sensors`.
+:::
+::::
+
+
+---------------------------------------------
+
+
+(guide-docs-classes-collections)=
+## Collection
+```python
+magpylib.Collection(*children, position, orientation, override_parent, style)
+```
+
+::::{grid} 2
+:::{grid-item}
+:columns: 9
+A `Collection` is a group of Magpylib objects that is used for common manipulation. All these objects are stored by reference in the <span style="color: orange">**children**</span> attribute. The collection becomes the <span style="color: orange">**parent**</span> of the object. An object can only have one parent. There are several options for accessing only specific children via the following properties
+
+* <span style="color: orange">**sources**</span>: return only sources
+* <span style="color: orange">**observers**</span>: return only observers
+* <span style="color: orange">**collections**</span>: return only collections
+* <span style="color: orange">**sources_all**</span>: return all sources, including the ones from sub-collections
+* <span style="color: orange">**observers_all**</span>: return all observers, including the ones from sub-collections
+* <span style="color: orange">**collections_all**</span>: return all collections, including the ones from sub-collections
+
+Additional methods for adding and removing children:
+
+- <span style="color: orange">**add()**</span>: Add an object to the collection
+- <span style="color: orange">**remove()**</span>: Remove an object from the collection
+:::
+:::{grid-item}
+:columns: 3
+![](../../../_static/images/docu_classes_init_collection.png)
+:::
+:::{grid-item}
+:columns: 12
+**Info:** A collection object has its own `position` and `orientation` attributes and spans a local reference frame for all its children. An operation applied to a collection moves the frame and is individually applied to all children such that their relative position in the local reference frame is maintained. This means that the collection functions as a container for manipulation, but child position and orientation are always updated in the global coordinate system. After being added to a collection, it is still possible to manipulate the individual children, which will also move them to a new relative position in the collection frame.
+
+Collections have **format** as an additional argument for **describe()** method. Default value is `format="type+id+label"`. Any combination of `"type"`, `"id"`, and `"label"` is allowed.
+
+A tutorial {ref}`examples-tutorial-collection` is provided in the example examples.
+:::
+::::
diff --git a/docs/_pages/user_guide/docs/docs_fieldcomp.md b/docs/_pages/user_guide/docs/docs_fieldcomp.md
new file mode 100644
index 000000000..0169fd07c
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_fieldcomp.md
@@ -0,0 +1,177 @@
+(docs-fieldcomp)=
+# Field Computation
+
+The following is a detailed technical documentation of Magpylib field computation.
+The tutorial {ref}`examples-tutorial-field-computation` shows good practices and illustrative examples.
+
+(docs-fieldcomp-oo)=
+## Object-oriented interface
+
+The object-oriented interface relies on the idea that sources of the magnetic field and observers thereof are created as Python objects which can be manipulated at will, and called for field computation. This is done via four top-level functions <span style="color: orange">**getB**</span>, <span style="color: orange">**getH**</span>, <span style="color: orange">**getJ**</span> and, <span style="color: orange">**getM**</span>,
+
+```python
+magpylib.getB(sources, observers, squeeze=True, pixel_agg=None, output="ndarray")
+magpylib.getH(sources, observers, squeeze=True, pixel_agg=None, output="ndarray")
+magpylib.getJ(sources, observers, squeeze=True, pixel_agg=None, output="ndarray")
+magpylib.getM(sources, observers, squeeze=True, pixel_agg=None, output="ndarray")
+```
+
+that compute the respective fields B (B-field), H (H-field), J (polarization) or M (magnetization) generated by `sources` as seen by the `observers` in their local coordinates. `sources` can be any Magpylib source object (e.g. magnets) or a flat list thereof. `observers` can be an array of position vectors with shape `(n1,n2,n3,...,3)`, any Magpylib observer object (e.g. sensors), or a flat list thereof. The following code shows a minimal example for Magpylib field computation.
+
+```python
+import magpylib as magpy
+
+# Define source and observer objects
+loop = magpy.current.Circle(current=1, diameter=0.001)
+sens = magpy.Sensor()
+
+# Compute field
+B = magpy.getB(loop, sens)
+
+print(B)
+#  --> [0.         0.         0.00125664]
+```
+
+For quick access, the functions `getBHJM` are also methods of all Magpylib objects, such that the `sources` or `observers` input is the object itself. The above example can be continued as
+
+```python
+# Call getB as method of loop
+B = loop.getB(sens)
+
+# Call getB as method of loop
+B = sens.getB(loop)
+```
+
+with the same result for `B`.
+
+By default, `getB` returns the B-field in units of T, `getH` the H-field in units of A/m, `getJ` the magnetic polarization in T and, `getM` the magnetization in A/m, assuming that all inputs are given in SI units as described in the docstrings.
+
+```{hint}
+In reality, `getB` is proportional to the `polarization` input and therefore returns the same unit. For example, with polarization input in mT, `getB` will return mT as well. At the same time when the `magnetization` input is kA/m, then `getH` returns kA/m as well. The B/H-field outputs are related to a M/J-inputs via a factor of $µ_0$.
+```
+
+The output of a field computation `magpy.getB(sources, observers)` is by default a NumPy array of shape `(l, m, k, n1, n2, n3, ..., 3)` where `l` is the number of input sources, `m` the (maximal) object path length, `k` the number of observers, `n1,n2,n3,...` the sensor pixel shape or the shape of the observer position array input and `3` the three magnetic field components $(B_x, B_y, B_z)$.
+
+* `squeeze`: If True (default) all axes of length 1 in the output (e.g. only a single source) are squeezed.
+
+* `pixel_agg`: Select a compatible NumPy aggregator function (e.g. `"min"`, `"mean"`) that is applied to the output. For example, with `pixel_agg="mean"` the mean field of all observer points is returned. With this option it is possible to supply `getBHJM` with multiple observers that have different pixel shapes.
+
+* `output`: Change the output format. Options are `"ndarray"` (default, returns a NumPy ndarray) and `"dataframe"` (returns a 2D-table Pandas DataFrame).
+
+```{note}
+Magpylib collects all inputs (object parameters), and vectorizes them for the computation which reduces the computation time dramatically for large inputs.
+
+Try to make all field computations with as few calls to `getBHJM` as possible. Avoid Python loops at all costs!
+```
+
+(docs-field-functional)=
+## Functional interface
+
+Users can bypass the object oriented functionality of Magpylib and instead compute the field for n given parameter sets. This is done by providing the following inputs to the top level functions `getB`, `getH`, `getJ` and, `getM`.
+
+1. `sources`: a string denoting the source type. Allowed values are the Magpylib source class names, see {ref}`docs-classes`.
+2. `observers`: array-like of shape (3,) or (n,3) giving the observer positions.
+3. `kwargs`: a dictionary with inputs of shape (x,) or (n,x). Must include all mandatory class-specific inputs. By default, `position=(0,0,0)` and `orientation=None`(=unit rotation).
+
+All "scalar" inputs of shape (x,) are automatically tiled up to shape (n,x) to create a set of n computation instances. The field is returned in the shape (n,3). The following code demonstrates the functional interface.
+
+```python
+import numpy as np
+import magpylib as magpy
+
+# All inputs and outputs in SI units
+
+# Compute the cuboid field for 3 input instances
+N = 3  # number of instances
+B = magpy.getB(
+    sources="Cuboid",
+    observers=np.linspace((0, 0, 1), (0, 0, 3), N),
+    dimension=np.linspace((1, 1, 1), (3, 3, 3), 3, N),
+    polarization=(0, 0, 1),
+)
+
+# This example demonstrates the scale invariance
+print(B)
+#  --> [[0.         0.         0.13478239]
+#       [0.         0.         0.13478239]
+#       [0.         0.         0.13478239]]
+```
+
+```{note}
+The functional interface is potentially faster than the object oriented one if users know how to generate the input arrays efficiently with numpy (e.g. `np.arange`, `np.linspace`, `np.tile`, `np.repeat`, ...).
+```
+
+
+(docs-field-core)=
+## Core interface
+
+At the heart of Magpylib lies a set of core functions that are our implementations of analytical field expressions found in the literature, see {ref}`guide-ressources-physics`. Direct access to these functions is given through the `magpylib.core` subpackage which includes,
+
+::::{grid} 1
+:gutter: 1
+
+:::{grid-item}
+<span style="color: orange">**magnet_cuboid_Bfield(**</span> `observers`, `dimensions`, `polarizations`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**magnet_cylinder_axial_Bfield(**</span> `z0`, `r`, `z`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**magnet_cylinder_diametral_Hfield(**</span> `z0`, `r`, `z`, `phi`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**magnet_cylinder_segment_Hfield(**</span> `observers`, `dimensions`, `magnetizations`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**magnet_sphere_Bfield(**</span>`observers`, `diameters`, `polarizations`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**current_circle_Hfield(**</span>`r0`, `r`, `z`, `i0`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**current_polyline_Hfield(**</span>`observers`, `segments_start`, `segments_end`, `currents`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**dipole_Hfield(**</span>`observers`, `moments`<span style="color: orange">**)**</span>
+:::
+
+:::{grid-item}
+<span style="color: orange">**triangle_Bfield(**</span>`observers`, `vertices`, `polarizations`<span style="color: orange">**)**</span>
+:::
+
+::::
+
+All inputs must be NumPy ndarrays of shape (n,x). Details can be found in the respective function docstrings. The following example demonstrates the core interface.
+
+
+```python
+import numpy as np
+import magpylib as magpy
+
+# All inputs and outputs in SI units
+
+# Prepare input
+z0 = np.array([1, 1])
+r = np.array([1, 1])
+z = np.array([2, 2])
+
+# Compute field with core functions
+B = magpy.core.magnet_cylinder_axial_Bfield(z0=z0, r=r, z=z).T
+
+print(B)
+#  --> [[0.05561469 0.         0.06690167]
+#       [0.05561469 0.         0.06690167]]
+```
+
+## Field computation workflow
+
+The Magpylib field computation internal workflow and different approaches of the three interfaces is outlined in the following sketch.
+
+![](../../../_static/images/docu_field_comp_flow.png)
diff --git a/docs/_pages/user_guide/docs/docs_graphics.md b/docs/_pages/user_guide/docs/docs_graphics.md
new file mode 100644
index 000000000..74ab19846
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_graphics.md
@@ -0,0 +1,470 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.1
+kernelspec:
+  display_name: Python 3 (ipykernel)
+  language: python
+  name: python3
+orphan: true
+---
+
+(guide-graphics)=
+# Graphic output
+
+(guide-graphics-show)=
+## 3D graphics with show
+
+Once all Magpylib objects and their paths have been created, `show` creates a 3D plot of the geometric arrangement using the Matplotlib (command line default) and Plotly (notebook default) packages. `show` generates a new figure which is automatically displayed.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+magnet = magpy.magnet.Cylinder(
+    polarization=(0, 0, 1),
+    dimension=(1, 1),
+)
+current = magpy.current.Circle(
+    current=1,
+    diameter=3,
+)
+dipole = magpy.misc.Dipole(
+    moment=(0, 0, 1),
+    position=np.linspace((2, 0, -2), (2, 0, 2), 20),
+)
+sensor = magpy.Sensor(
+    pixel=[(0, 0, z) for z in (-0.5, 0, 0.5)],
+    position=(-2, 0, 0),
+)
+magpy.show(magnet, current, dipole, sensor)
+```
+
+Notice that objects and their paths are automatically assigned different colors. The polarization of the magnet is displayed by default (Plotly and Pyvista) by coloring the poles, which overwrites the object color. In Matplotlib the polarization is by default displayed by an arrow. Current directions and dipole objects are indicated by arrows and sensors are shown as tri-colored coordinate cross with pixel as markers.
+
+How objects are represented graphically (color, line thickness, etc.) is defined by their [style properties](guide-graphic-styles).
+
+(guide-graphic-backends)=
+## Graphic backends
+
+The graphic backend refers to the plotting library that is used for graphic output. A plotting canvas refers to the frame/window/canvas/axes object the graphic output is forwarded to.
+
+The graphic backend is set via the kwarg `backend` in the `show` function, which is demonstrated in the following example
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Define sources and paths
+loop = magpy.current.Circle(
+    current=1, diameter=1, position=np.linspace((0, 0, -3), (0, 0, 3), 40)
+)
+cylinder = magpy.magnet.Cylinder(
+    polarization=(0, -1, 0), dimension=(1, 2), position=(0, -3, 0)
+)
+cylinder.rotate_from_angax(np.linspace(0, 300, 40), "z", anchor=0, start=0)
+
+for backend in magpy.SUPPORTED_PLOTTING_BACKENDS:
+    print(backend)
+    magpy.show(loop, cylinder, backend=backend)
+```
+
+With the installation default setting, `backend='auto'`, Magpylib infers the graphic backend from the environment running the code, or from the requested canvas.
+
+| environment      | canvas                                          | inferred backend |
+|------------------|-------------------------------------------------|------------------|
+| Command-Line     | `None`                                          | `matplotlib`     |
+| IPython notebook | `None`                                          | `plotly`         |
+| all              | `matplotlib.axes.Axes`                          | `matplotlib`     |
+| all              | `plotly.graph_objects.Figure` or `FigureWidget` | `plotly`         |
+| all              | `pyvista.Plotter`                               | `pyvista`        |
+
+The library default can be changed, e.g. with the command `magpy.defaults.display.backend = 'plotly'`.
+
+There is a high level of **feature parity**, however, not all graphic features are supported by all backends, and not all graphic features work equally well, so that [default style settings](guide-graphic-styles-default) differ slightly. In addition, some common Matplotlib syntax (e.g. color `'r'`, linestyle `':'`) is automatically translated to other backends.
+
+|        Feature           | Matplotlib | Plotly | Pyvista |
+|:------------------------:|:----------:|:------:|:-------:|
+| triangular mesh 3d       | ✔️         | ✔️    | ✔️      |
+| line 3d                  | ✔️         | ✔️    | ✔️      |
+| line style               | ✔️         | ✔️    | ❌      |
+| line color               | ✔️         | ✔️    | ✔️      |
+| line width               | ✔️         | ✔️    | ✔️      |
+| marker 3d                | ✔️         | ✔️    | ✔️      |
+| marker color             | ✔️         | ✔️    | ✔️      |
+| marker size              | ✔️         | ✔️    | ✔️      |
+| marker symbol            | ✔️         | ✔️    | ❌      |
+| marker numbering         | ✔️         | ✔️    | ❌      |
+| zoom level               | ✔️         | ✔️    | ❌[2]   |
+| magnetization color      | ✔️[7]      | ✔️    | ✔️      |
+| animation                | ✔️         | ✔️    | ✔️[5]   |
+| animation time           | ✔️         | ✔️    | ✔️[5]   |
+| animation fps            | ✔️         | ✔️    | ✔️[5]   |
+| animation slider         | ✔️[1]      | ✔️    | ❌      |
+| subplots 2D              | ✔️         | ✔️    | ✔️[6]   |
+| subplots 3D              | ✔️         | ✔️    | ✔️      |
+| user canvas              | ✔️         | ✔️    | ✔️      |
+| user extra 3d model [3]  | ✔️         | ✔️    | ✔️[4]   |
+
+[1]: when returning animation object and exporting it as jshtml.
+
+[2]: possible but not implemented at the moment.
+
+[3]: only `"scatter3d"`, and `"mesh3d"`. Gets "translated" to every other backend.
+
+[4]: custom user defined trace constructors  allowed, which are specific to the backend.
+
+[5]: animation is only available through export as `gif` or `mp4`
+
+[6]: 2D plots are not supported for all jupyter_backends. As of pyvista>=0.38 these are deprecated and replaced by the [trame](https://docs.pyvista.org/api/plotting/trame.html) backend.
+
+[7]: Matplotlib does not support color gradient. Instead magnetization is shown through object slicing and coloring.
+
+`show` will also pass on all kwargs to the respective plotting backends. For example, in the [animation sample code](guide-graphic-animations) the kwarg `show_legend` is forwarded to the Plotly backend.
+
+
+(guide-graphics-canvas)=
+## Plotting canvas
+
+When calling `show`, a figure is automatically generated and displayed. It is also possible to place the `show` output in a given figure using the `canvas` argument. Consider the following Magpylib field computation,
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Magpylib field computation
+loop = magpy.current.Circle(current=1, diameter=0.1)
+sens = magpy.Sensor(position=np.linspace((0, 0, -0.1), (0, 0, 0.1), 100))
+B = loop.getB(sens)
+```
+
+The following examples demonstrate how to place the Magpylib `show` output in figures created with the three supported graphic backends.
+
++++
+
+In **Matplotlib** we combine a 2D-field plot with the 3D show output and modify the 3D show output with a line.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import matplotlib.pyplot as plt
+import numpy as np
+
+# Magpylib field computation
+loop = magpy.current.Circle(current=1, diameter=0.1)
+sens = magpy.Sensor(position=np.linspace((0, 0, -0.1), (0, 0, 0.1), 100))
+B = loop.getB(sens)
+
+# Create Matplotlib figure with subplots
+fig = plt.figure(figsize=(10, 4))
+ax1 = fig.add_subplot(121)
+ax2 = fig.add_subplot(122, projection="3d")
+
+# 2D Matplotlib plot
+ax1.plot(B)
+
+# Place Magpylib show output in Matplotlib figure
+magpy.show(loop, sens, canvas=ax2)
+
+# Modify show output
+ax2.plot([-0.1, 0.1], [0, 0], [0, 0], color="k")
+
+# Render figure
+plt.tight_layout()
+plt.show()
+```
+
+```{attention}
+When providing a canvas, no update to its layout is performed by Magpylib, unless explicitly specified by setting `canvas_update=True` in `show()`. By default `canvas_update="auto"` only updates the canvas if is not provided by the user. The example above outputs a 3D scene with the default Matplotlib settings and will not match the standard Magpylib settings.
+```
+
++++
+
+In **Plotly** we combine a 2D-field plot with the 3D show output and modify the 3D show output with a line.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+import plotly.graph_objects as go
+
+# Magpylib field computation
+loop = magpy.current.Circle(current=1, diameter=0.1)
+sens = magpy.Sensor(position=np.linspace((0, 0, -0.1), (0, 0, 0.1), 100))
+B = loop.getB(sens)
+
+# Create Plotly figure and subplots
+fig = go.Figure().set_subplots(
+    rows=1, cols=2, specs=[[{"type": "xy"}, {"type": "scene"}]]
+)
+
+# 2D Plotly plot
+fig.add_scatter(y=B[:, 2], name="Bz")
+
+# Draw 3d model in the existing Plotly figure
+magpy.show(loop, sens, canvas=fig, col=2, canvas_update=True)
+
+# Add 3d scatter trace to main figure model
+fig.add_scatter3d(x=(-0.1, 0.1), y=(0, 0), z=(0, 0), col=2, row=1)
+
+# Render figure
+fig.show()
+```
+
+**Pyvista** is not made for 2D plotting. Here we simply add a line to the 3D show output.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import pyvista as pv
+
+# Create Pyvista scene
+pl = pv.Plotter()
+
+# Place Magpylib show output in Pyvista scene
+magpy.show(loop, sens, canvas=pl)
+
+# Add a Line to 3D scene
+line = np.array([(-0.1, 0, 0), (0.1, 0, 0)])
+pl.add_lines(line, color="black")
+
+# Render figure
+pl.show()
+```
+
+(guide-graphics-return_fig)=
+## Return figure
+
+Instead of forwarding a figure to an existing canvas, it is also possible to return the figure object for further manipulation using the `return_fig` command. In the following example this is demonstrated for the pyvista backend.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop = magpy.current.Circle(current=1, diameter=0.1)
+sens = magpy.Sensor(position=np.linspace((0, 0, -0.1), (0, 0, 0.1), 100))
+
+# Return pyvista scene object with show
+pl = magpy.show(loop, sens, backend="pyvista", return_fig=True)
+
+# Modify Pyvista scene
+pl.add_lines(np.array([(-0.1, 0, 0), (0.1, 0, 0)]), color="black")
+pl.camera.position = (0.5, 0.2, 0.1)
+pl.set_background("yellow", top="lightgreen")
+pl.enable_anti_aliasing("ssaa")
+
+# Display scene
+pl.show()
+```
+
+(guide-graphic-animations)=
+## Animation
+
+The Magpylib [object paths](docs-position-paths) visualized with `show` can be animated by setting the kwarg `animation=True`. This synergize specifically well with the Plotly backend.
+
+The animations can be fine-tuned with the following kwargs of `show`:
+1. `animation_time` (default=3), must be a positive number that gives the animation time in seconds.
+2. `animation_slider` (default=`True`), is boolean and sets if a slider should be displayed.
+3. `animation_fps` (default=30), sets the maximal frames per second.
+
+Each path step will generate one frame of the animation, unless `animation_fps` would be exceeded. In this case specific equidistant frames will be selected automatically to adjust to the limited display possibilities. For practicality, the input `animation=x` will automatically set `animation=True` and `animation_time=x`.
+
+The following example demonstrates the animation feature,
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop = magpy.current.Circle(current=1, diameter=0.1)
+sens = magpy.Sensor(position=np.linspace((0, 0, -0.1), (0, 0, 0.1), 100))
+
+# Show animation
+magpy.show(
+    loop,
+    sens,
+    animation=1,
+    animation_fps=20,
+    animation_slider=True,
+    backend="plotly",
+    showlegend=False,
+)
+```
+
+```{warning}
+Even with some implemented fail safes, such as a maximum frame rate and frame count, there is no guarantee that the animation will be rendered properly. This is particularly relevant when the user tries to animate many objects and/or many path positions at the same time.
+```
+
+(guide-graphics-subplots)=
+## Built-in Subplots
+
+:::{versionadded} 4.4
+Coupled subplots
+:::
+
+It is often tedious to integrate the Magpylib `show` output into sub-plots as shown above, especially when dealing with animations and combinations of 2D and 3D plots.
+
+For this, Magpylib offers the possibility to show the sensor output along a path in addition to the 3D-output, and to place 2D and 3D outputs in subplots.
+
+### With show
+
+All of this is achieved via the `show` function by passing input objects as dictionaries with the arguments.
+
+1. `objects`: list of Magpylib objects
+2. `col`: int which selects the subplot column. Default is `col=1`.
+3. `row`: int which selects the subplot row. Default is `row=1`.
+4. `output`: string which selects the type of output that should be displayed in this subplot. Options are
+
+    1. `"model3d"` is the default value and selects the 3D output.
+    2. `"Xa"` selects a 2D line-plot of a field component (combination) as seen by the sensor(s) along their path. The sensor(s) must be part of the `objects` input. Here "X" selects the field and must be one of "BHJM", and "a" selects the respective component combination and must be a subset of "xyz". For example, `output=Hx` displays the x-component of the H-field, or `output=Bxz` displays `sqrt(|Bx|² + |Bz|²)`. By default, source outputs are summed up (`sumup=True`) and sensor pixels, are aggregated by mean (`pixel_agg="mean"`).
+
+The following code demonstrates these features.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop = magpy.current.Circle(current=1, diameter=0.1, style_label="L")
+sens = magpy.Sensor(
+    position=np.linspace((-0.1, 0, 0.1), (0.1, 0, 0.1), 50), style_label="S"
+)
+
+# Use built-in subplots
+magpy.show(
+    {"objects": [loop, sens]},
+    {"objects": [loop, sens], "output": "Bx", "col": 2},
+    {"objects": [loop, sens], "output": ["Hx", "Hy", "Hz"], "row": 2},
+    {"objects": [loop, sens], "output": "Hxyz", "col": 2, "row": 2},
+    backend="matplotlib",
+)
+```
+
+Each input dictionary can contain kwargs, like `pixel_agg=None` or `sumup=False` for 2D plots.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop1 = magpy.current.Circle(current=1, diameter=0.1, style_label="L1")
+loop2 = loop1.copy(diameter=0.2, style_label="L2")
+sens = magpy.Sensor(
+    pixel=[(0.01, 0, 0), (-0.01, 0, 0)],
+    position=np.linspace((-0.2, 0, 0.1), (0.2, 0, 0.1), 50),
+    style_label="S",
+)
+obj = [loop1, loop2, sens]
+
+# Use built-in subplots
+magpy.show(
+    {"objects": obj, "output": "Hx"},
+    {"objects": obj, "output": "Hx", "pixel_agg": None, "col": 2},
+    {"objects": obj, "output": "Hx", "sumup": False, "row": 2},
+    {
+        "objects": obj,
+        "output": "Hx",
+        "pixel_agg": None,
+        "sumup": False,
+        "row": 2,
+        "col": 2,
+    },
+)
+```
+
+(guide-graphics-show_context)=
+### With show_context
+
+To make the subplot syntax more convenient we introduced the `show_context` native Python context manager. It allows to defer calls to the `show` function while passing additional arguments. This is necessary for Magpylib to know how many rows and columns are requested by the user, which single `show` calls do not keep track of. All kwargs, e.g. `backend` are handed directly to the context manager.
+
+The above example becomes:
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop = magpy.current.Circle(current=1, diameter=0.1, style_label="L")
+sens = magpy.Sensor(
+    position=np.linspace((-0.1, 0, 0.1), (0.1, 0, 0.1), 50), style_label="S"
+)
+
+# Use built-in subplots via show_context
+with magpy.show_context(loop, sens, backend="plotly") as sc:
+    sc.show()
+    sc.show(output="Bx", col=2)
+    sc.show(output=["Hx", "Hy", "Hz"], row=2)
+    sc.show(output="Hxyz", col=2, row=2)
+```
+
+````{note}
+Using the context manager object as in:
+
+```python
+import magpylib as magpy
+
+obj1 = magpy.magnet.Cuboid()
+obj2 = magpy.magnet.Cylinder()
+
+with magpy.show_context() as sc:
+    sc.show(obj1, col=1)
+    sc.show(obj2, col=2)
+```
+
+is equivalent to the use of `magpylib.show` directly, as long as within the context manager:
+
+```python
+import magpylib as magpy
+
+obj1 = magpy.magnet.Cuboid()
+obj2 = magpy.magnet.Cylinder()
+
+with magpy.show_context():
+    magpy.show(obj1, col=1)
+    magpy.show(obj2, col=2)
+```
+````
+
+### Coupled 2D/3D Animation
+
+It is very helpful to combine 2D and 3D subplots in an animation that shows the motion of the 3D system, while displaying the field at the respective path instance at the same time. Unfortunately, it is quite tedious to create such animations. The most powerful feature and main reason behind built-in subplots is the ability to do just that with few lines of code.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create Magpylib objects with paths
+loop = magpy.current.Circle(current=1, diameter=0.1, style_label="L")
+sens = magpy.Sensor(
+    position=np.linspace((-0.1, 0, 0.1), (0.1, 0, 0.1), 50), style_label="S"
+)
+
+# Use built-in subplots via show_context
+with magpy.show_context(loop, sens, animation=True) as sc:
+    sc.show()
+    sc.show(output="Bx", col=2)
+    sc.show(output=["Hx", "Hy", "Hz"], row=2)
+    sc.show(output="Hxyz", col=2, row=2)
+```
+
+### Canvas length units
+
+When displaying very small Magpylib objects, the axes scaling in meters might be inadequate and you may want to use other units that fit the system dimensions more nicely. The example below shows how to display an object (in this case the same) with different length units and zoom levels.
+
+```{tip}
+Setting `units_length="auto"` will infer the most suitable units based on the maximum range of the system.
+```
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+c1 = magpy.magnet.Cuboid(dimension=(0.001, 0.001, 0.001), polarization=(1, 2, 3))
+
+with magpy.show_context(c1, backend="matplotlib") as s:
+    s.show(row=1, col=1, units_length="auto", zoom=0)
+    s.show(row=1, col=2, units_length="mm", zoom=1)
+    s.show(row=2, col=1, units_length="µm", zoom=2)
+    s.show(row=2, col=2, units_length="m", zoom=3)
+```
diff --git a/docs/_pages/user_guide/docs/docs_magpylib_force.md b/docs/_pages/user_guide/docs/docs_magpylib_force.md
new file mode 100644
index 000000000..fedc88b7d
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_magpylib_force.md
@@ -0,0 +1,94 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(docs-magpylib-force)=
+# Magpylib Force v0.3.1
+
+The package `magpylib-force` provides an addon for magnetic force and torque computation between magpylib source objects.
+
+## Installation
+
+Install `magpylib-force` with pip:
+
+```console
+pip install magpylib-force
+```
+
+## API
+
+The package provides only a single top-level function <span style="color: orange">**getFT()**</span> for computing force and torque.
+
+```python
+import magpylib_force as mforce
+
+mforce.getFT(sources, targets, anchor, eps=1e-5, squeeze=True)
+```
+
+Here `sources` are Magpylib source objects that generate the magnetic field. The `targets` are the objects on which the magnetic field of the sources acts to generate force and torque. With current version 0.3.1 only `Cuboid`, `Cylinder`, `CylinderSegment`, `Polyline`, and `Circle` objects can be targets. The `anchor` denotes an anchor point which is the barycenter of the target. If no barycenter is given, homogeneous mass density is assumed and the geometric center of the target is chose as it's barycenter. `eps` refers to the finite difference length when computing the magnetic field gradient and should be adjusted to be much smaller than size of the system. `squeeze` can be used to squeeze the output array dimensions as in Magpylib's `getB`, `getH`, `getJ`, and `getM`.
+
+The computation is based on numerically integrating the magnetic field generated by the `sources` over the `targets`, see [here](docs-force-computation) for more details. This requires that each target has a <span style="color: orange">**meshing**</span> directive, which must be provided via an attribute to the object. How `meshing` is defined:
+
+For all objects as an integer, which defines the target number of mesh-points. In some cases an algorithm will attempt to come close to this number by splitting up magnets into quasi-cubical cells. Exceptions are:
+
+- `Cuboid`: takes also a 3-vector that defines the number of equidistant splits along each axis resulting in a rectangular regular grid. Keep in mind that the accuracy is increased by cubical aspect ratios.
+- `PolyLine`: defines the number of equidistant splits of each PolyLine segment, not of the whole multi-segmented object. The total number of mesh-points will be number of segments times meshing.
+
+The function `getFT()` returns force and torque as `np.ndarray` of shape (2,3), or (t,2,3) when t targets are given.
+
+The following example code computes the force acting on a cuboid magnet, generated by a current loop.
+
+```python
+import magpylib as magpy
+import magpylib_force as mforce
+
+# create source and target objects
+loop = magpy.current.Circle(diameter=2e-3, current=10, position=(0, 0, -1e-3))
+cube = magpy.magnet.Cuboid(dimension=(1e-3, 1e-3, 1e-3), polarization=(1, 0, 0))
+
+# provide meshing for target object
+cube.meshing = (5, 5, 5)
+
+# compute force and torque
+FT = mforce.getFT(loop, cube)
+print(FT)
+# [[ 1.36304272e-03  6.35274710e-22  6.18334051e-20]  # force in N
+#  [-0.00000000e+00 -1.77583097e-06  1.69572026e-23]] # torque in Nm
+```
+
+```{warning}
+[Scaling invariance](guide-docs-io-scale-invariance) does not hold for force computations! Be careful to provide the inputs in the correct units!
+```
+
+(docs-force-computation)=
+## Computation details
+
+The force $\vec{F}_m$ acting on a magnetization distribution $\vec{M}$ in a magnetic field $\vec{B}$ is given by
+
+$$\vec{F}_m = \int \nabla (\vec{M}\cdot\vec{B}) \ dV.$$
+
+The torque $\vec{T}_m$ which acts on the magnetization distribution is
+
+$$\vec{T}_m = \int \vec{M} \times \vec{B} \ dV.$$
+
+The force $\vec{F}_c$ which acts on a current distribution $\vec{j}$ in a magnetic field is
+
+$$\vec{F}_c = \int \vec{j}\times \vec{B} \ dV.$$
+
+And there is no torque. However, one must not forget that a force, when applied off-center, adds to the torque as
+
+$$\vec{T}' = \int \vec{r} \times \vec{F} \ dV,$$
+
+where $\vec{r}$ points from the body barycenter to the position where the force is applied.
+
+The idea behind `magplyib-force` is to compute the above integrals by discretization. For this purpose, the target body is split up into small cells using the object `meshing` attribute. The force and torque computation is performed for all cells in a vectorized form, and the sum is returned.
diff --git a/docs/_pages/user_guide/docs/docs_pos_ori.md b/docs/_pages/user_guide/docs/docs_pos_ori.md
new file mode 100644
index 000000000..61319a0de
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_pos_ori.md
@@ -0,0 +1,168 @@
+(docs-position)=
+# Position, Orientation, and Paths
+
+The following sections are detailed technical documentations of the Magpylib position and orientation interface. Practical examples and good practice usage is demonstrated in the tutorial {ref}`examples-tutorial-paths`.
+
+::::{grid} 2
+:gutter: 2
+
+:::{grid-item}
+:columns: 12 7 7 7
+The analytical magnetic field expressions found in the literature, implemented in the [Magpylib core](docs-field-core), are given in native coordinates of the sources which is convenient for the mathematical formulation. It is a common problem to transform the field into an application relevant observer coordinate system. While not technically difficult, such transformations are prone to error.
+:::
+:::{grid-item}
+:columns: 12 5 5 5
+![](../../../_static/images/docu_position_sketch.png)
+:::
+::::
+
+Here Magpylib helps. All Magpylib sources and observers lie in a global Cartesian coordinate system. Object position and orientation are defined by the attributes `position` and `orientation`, 😏. Objects can easily be moved around using the `move()` and `rotate()` methods. Eventually, the field is computed in the reference frame of the observers (e.g. Sensor objects). Positions are given in units of meter, and the default unit for orientation is °.
+
+(docs-position-paths)=
+## Position and orientation attributes
+
+Position and orientation of all Magpylib objects are defined by the two attributes
+
+::::{grid} 2
+:gutter: 2
+
+:::{grid-item-card}
+:shadow: none
+:columns: 12 5 5 5
+<span style="color: orange">**position**</span> - a point $(x,y,z)$ in the global coordinates, or a set of such points $(\vec{P}_1, \vec{P}_2, ...)$. By default objects are created with `position=(0,0,0)`.
+:::
+:::{grid-item-card}
+:shadow: none
+:columns: 12 7 7 7
+<span style="color: orange">**orientation**</span> - a [Scipy Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html) which describes the object rotation relative to its default orientation (defined in {ref}`docs-classes`). By default, objects are created with unit rotation `orientation=None`.
+:::
+::::
+
+The position and orientation attributes can be either **scalar**, i.e. a single position or a single rotation, or **vector**, when they are arrays of such scalars. The two attributes together define the **path** of an object - Magpylib makes sure that they are always of the same length. When the field is computed, it is automatically computed for the whole path.
+
+```{tip}
+To enable vectorized field computation, paths should always be used when modeling multiple object positions. Avoid using Python loops at all costs for that purpose! If your path is difficult to realize, consider using the [functional interface](docs-field-functional) instead.
+```
+
+## Move and Rotate
+
+Magpylib offers two powerful methods for object manipulation:
+
+::::{grid} 2
+:gutter: 2
+
+:::{grid-item-card}
+:columns: 12 5 5 5
+:shadow: none
+<span style="color: orange">**move(**</span>`displacement`, `start="auto"`<span style="color: orange">**)**</span> -  move object by `displacement` input. `displacement` is a position vector (scalar input) or a set of position vectors (vector input).
+:::
+:::{grid-item-card}
+:columns: 12 7 7 7
+:shadow: none
+<span style="color: orange">**rotate(**</span>`rotation`, `anchor=None`, `start="auto"`<span style="color: orange">**)**</span> - rotates the object by the `rotation` input about an anchor point defined by the `anchor` input. `rotation` is a [Scipy Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html), and `anchor` is a position vector. Both can be scalar or vector inputs. With `anchor=None` the object is rotated about its `position`.
+:::
+::::
+
+- **Scalar input** is applied to the whole object path, starting with path index `start`. With the default `start="auto"` the index is set to `start=0` and the functionality is **moving objects around** (incl. their whole paths).
+- **Vector input** of length $n$ applies the $n$ individual operations to $n$ object path entries, starting with path index `start`. Padding applies when the input exceeds the existing path length. With the default `start="auto"` the index is set to `start=len(object path)` and the functionality is **appending the input**.
+
+The practical application of this formalism is best demonstrated by the following program
+
+```python
+import magpylib as magpy
+
+# Note that all units are in SI
+
+sensor = magpy.Sensor()
+print(sensor.position)  # Default value
+#   --> [0. 0. 0.]
+
+sensor.move((1, 1, 1))  # Scalar input is by default applied
+print(sensor.position)  # to the whole path
+#   --> [1. 1. 1.]
+
+sensor.move([(1, 1, 1), (2, 2, 2)])  # Vector input is by default appended
+print(sensor.position)  # to the existing path
+#   --> [[1. 1. 1.]  [2. 2. 2.]  [3. 3. 3.]]
+
+sensor.move((1, 1, 1), start=1)  # Scalar input and start=1 is applied
+print(sensor.position)  # to whole path starting at index 1
+#   --> [[1. 1. 1.]  [3. 3. 3.]  [4. 4. 4.]]
+
+sensor.move([(0, 0, 10), (0, 0, 20)], start=1)  # Vector input and start=1 merges
+print(sensor.position)  # the input with the existing path
+#   --> [[ 1.  1.  1.]  [ 3.  3. 13.]  [ 4.  4. 24.]]     # starting at index 1.
+```
+
+Several extensions of the `rotate` method give a lot of flexibility with object rotation. They all feature the arguments `anchor` and `start` which work as described above.
+
+::::{grid} 1
+:gutter: 2
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_angax(**</span>`angle`, `axis`, `anchor=None`, `start="auto"`, `degrees=True` <span style="color: orange">**)**</span>
+* `angle`: scalar or array with shape (n). Angle(s) of rotation.
+* `axis`: array of shape (3,) or string. The direction of the rotation axis. String input can be 'x', 'y' or 'z' to denote respective directions.
+* `degrees`: bool, default=True. Interpret angle input in units of deg (True) or rad (False).
+:::
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_rotvec(**</span>`rotvec`, `anchor=None`, `start="auto"`, `degrees=True` <span style="color: orange">**)**</span>
+* `rotvec` : array with shape (n,3) or (3,). The rotation vector direction is the rotation axis and the vector length is the rotation angle in units of deg.
+* `degrees`: bool, default=True. Interpret angle input in units of deg (True) or rad (False).
+:::
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_euler(**</span> `angle`, `seq`, `anchor=None`, `start="auto"`, `degrees=True` <span style="color: orange">**)**</span>
+* `angle`: scalar or array with shape (n). Angle(s) of rotation in units of deg (by default).
+* `seq` : string. Specifies sequence of axes for rotations. Up to 3 characters belonging to the set {'X', 'Y', 'Z'} for intrinsic rotations, or {'x', 'y', 'z'} for extrinsic rotations. Extrinsic and intrinsic rotations cannot be mixed in one function call.
+* `degrees`: bool, default=True. Interpret angle input in units of deg (True) or rad (False).
+:::
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_quat(**</span>`quat`, `anchor=None`, `start="auto"` <span style="color: orange">**)**</span>
+* `quat` : array with shape (n,4) or (4,). Rotation input in quaternion form.
+:::
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_mrp(**</span>`matrix`, `anchor=None`, `start="auto"` <span style="color: orange">**)**</span>
+* `matrix` : array with shape (n,3,3) or (3,3). Rotation matrix. See scipy.spatial.transform.Rotation for details.
+:::
+
+:::{grid-item-card}
+:columns: 12
+:shadow: none
+<span style="color: orange">**rotate_from_mrp(**</span>`mrp`, `anchor=None`, `start="auto"` <span style="color: orange">**)**</span>
+* `mrp` : array with shape (n,3) or (3,). Modified Rodrigues parameter input. See scipy Rotation package for details.
+:::
+
+::::
+
+When objects with different path lengths are combined, e.g. when computing the field, the shorter paths are treated as static beyond their end to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths.
+
+::::{grid} 2
+:gutter: 2
+
+:::{grid-item-card}
+:columns: 12 7 7 7
+:shadow: none
+**Edge-padding:** whenever path entries beyond the existing path length are needed the edge-entries of the existing path are returned. This means that the object is “static” beyond its existing path.
+:::
+:::{grid-item-card}
+:columns: 12 5 5 5
+:shadow: none
+**End-slicing:** whenever a path is automatically reduced in length, Magpylib will slice such that the ending of the path is kept.
+:::
+::::
+
+The tutorial {ref}`examples-tutorial-paths` shows intuitive good practice examples of the important functionality described in this section.
diff --git a/docs/_pages/user_guide/docs/docs_styles.md b/docs/_pages/user_guide/docs/docs_styles.md
new file mode 100644
index 000000000..bdbd3a7ab
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_styles.md
@@ -0,0 +1,563 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(guide-graphic-styles)=
+# Graphic styles
+
+The graphic styles define how Magpylib objects are displayed visually when calling `show`. They can be fine-tuned and individualized to suit requirements and taste.
+
+Graphic styles can be defined in various ways:
+
+1. There is a **default style** setting which is applied when no other inputs are made.
+2. An **individual style** can be defined at object level. If the object is a [Collection](guide-docs-classes-collections) it will apply its color to all children.
+3. Finally, styles that are defined in the `show` function call will override all other settings. This is referred to as **local style override**.
+
+The following sections describe these styling options and how to customize them.
+
+(guide-graphic-styles-default)=
+## Default style
+
+The default style is stored in `magpylib.defaults.display.style`. Note that the installation default styles differ slightly between different [graphic backends](guide-graphic-backends) depending on their respective capabilities. Specifically, the magnet magnetization in Matplotlib is displayed with arrows by default, while it is displayed using a color scheme in Plotly and Pyvista. The color scheme is also implemented in Matplotlib, but it is visually unsatisfactory.
+
+The default styles can be modified in three ways:
+
+1. By setting the default properties,
+
+```python
+magpy.defaults.display.style.magnet.magnetization.show = True
+magpy.defaults.display.style.magnet.magnetization.color.mode = "bicolor"
+magpy.defaults.display.style.magnet.magnetization.color.north = "grey"
+```
+
+2. By assigning a style dictionary with equivalent keys,
+
+```python
+magpy.defaults.display.style.magnet = {
+    "magnetization": {"show": True, "color": {"north": "grey", "mode": "tricolor"}}
+}
+```
+
+3. By making use of the `update` method:
+
+```python
+magpy.defaults.display.style.magnet.magnetization.update(
+    show=True, color={"north": "grey", "mode": "tricolor"}
+)
+```
+
+All three examples result in the same default style. Once modified, the library default can always be restored with the `magpylib.style.reset()` method. The following practical example demonstrates how to create and set a user defined magnet magnetization style as default. The chosen custom style combines a 3-color scheme with an arrow which points in the magnetization direction.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Define Magpylib magnet objects
+cube = magpy.magnet.Cuboid(polarization=(1, 0, 0), dimension=(1, 1, 1))
+cylinder = magpy.magnet.Cylinder(
+    polarization=(0, -1, 0), dimension=(1, 1), position=(2, 0, 0)
+)
+sphere = magpy.magnet.Sphere(
+    polarization=(0, 1, 1), diameter=1, position=(4, 0, 0)
+)
+
+# Show with Magpylib default style
+print("Default magnetization style")
+magpy.show(cube, cylinder, sphere, backend="plotly")
+
+# Create and set user-defined default style for magnetization
+user_magnetization_style = {
+    "show": True,
+    "mode": "arrow+color",
+    "size": 1,
+    "arrow": {
+        "color": "black",
+        "offset": 1,
+        "show": True,
+        "size": 2,
+        "sizemode": "scaled",
+        "style": "solid",
+        "width": 3,
+    },
+    "color": {
+        "transition": 0,
+        "mode": "tricolor",
+        "middle": "white",
+        "north": "magenta",
+        "south": "turquoise",
+    },
+}
+magpy.defaults.display.style.magnet.magnetization = user_magnetization_style
+
+# Show with new default style
+print("Custom magnetization style")
+magpy.show(cube, cylinder, sphere, backend="plotly")
+```
+
+```{note}
+The default Magpylib style abides by the tri-color scheme for ideal-typical magnetic scales introduced in the DIN specification [91411](https://www.dinmedia.de/de/technische-regel/din-spec-91411/354972979) and its succeeding standard DIN SPEC 91479.
+```
+
+A list of all style options can be found [here](examples-list-of-styles).
+
+
+## Magic underscore notation
+
+To facilitate working with deeply nested properties, all style constructors and object style methods support the "magic underscore notation". It enables referencing nested properties by joining together multiple property names with underscores. This feature mainly helps reduce the code verbosity and is heavily inspired by the [Plotly underscore notation](https://plotly.com/python/creating-and-updating-figures/#magic-underscore-notation)).
+
+With magic underscore notation, the previous examples can be written as:
+
+```python
+import magpylib as magpy
+
+magpy.defaults.display.style.magnet = {
+    "magnetization_show": True,
+    "magnetization_color_middle": "grey",
+    "magnetization_color_mode": "tricolor",
+}
+```
+
+or directly as named keywords in the `update` method as:
+
+```python
+import magpylib as magpy
+
+magpy.defaults.display.style.magnet.update(
+    magnetization_show=True,
+    magnetization_color_middle="grey",
+    magnetization_color_mode="tricolor",
+)
+```
+
+## Individual style
+
+Any Magpylib object can have its own individual style that will take precedence over the default values when `show` is called. When setting individual styles, the object family specifier such as `magnet` or `current` can be omitted.
+
+```{note}
+Users should be aware that the individual object style is organized in classes that take much longer to initialize than bare Magpylib objects, i.e. objects without individual style. This can lead to a computational bottleneck when setting individual styles of many Magpylib objects. For this reason Magpylib automatically defers style initialization until it is needed the first time, e.g. when calling the `show` function, so that object creation time is not affected. However, this works only if style properties are set at initialization (e.g.: `magpy.magnet.Cuboid(..., style_label="MyCuboid")`). While this effect may not be noticeable for a small number of objects, it is best to avoid setting styles until it is plotting time.
+```
+
+In the following example `cube` has no individual style, so the default style is used. `cylinder` has an individual style set for `magnetization` which is a tricolor scheme that will display the object color in the middle. The individual style is set at object initialization (good practice), and it will be applied only when `show` is called at the end of the example. Finally, `sphere` is also given an individual style for `magnetization` that displays the latter using a 2-color scheme. In this case, however, the individual style is applied after object initialization (bad practice), which results in style initialization before it is needed.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Reset defaults from previous example
+magpy.defaults.reset()
+
+# Default style
+cube = magpy.magnet.Cuboid(
+    polarization=(1, 0, 0),
+    dimension=(1, 1, 1),
+)
+
+# Good practice: define individual style at object init
+cylinder = magpy.magnet.Cylinder(
+    polarization=(0, 1, 0),
+    dimension=(1, 1),
+    position=(2, 0, 0),
+    style_magnetization_color_mode="tricycle",
+)
+
+# Bad practice: set individual style after object init
+sphere = magpy.magnet.Sphere(
+    polarization=(0, 1, 1),
+    diameter=1,
+    position=(4, 0, 0),
+)
+sphere.style.magnetization.color.mode = "bicolor"
+
+# Show styled objects
+magpy.show(cube, cylinder, sphere, backend="plotly")
+```
+
+## Collection style
+
+When displaying [Collection objects](guide-docs-classes-collections) their `color` property will be assigned to all its children override the default color cycle. In the following example this is demonstrated. Therefore, we make use of the [Matplotlib backend](guide-graphic-backends) which displays magnet color by default and shows the magnetization as an arrow rather than a color sequence.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Define 3 magnets
+cube = magpy.magnet.Cuboid(
+    polarization=(1,0,0), dimension=(1,1,1)
+)
+cylinder = magpy.magnet.Cylinder(
+    polarization=(0,1,0), dimension=(1,1), position=(2,0,0)
+)
+sphere = magpy.magnet.Sphere(
+    polarization=(0,1,1), diameter=1, position=(4,0,0)
+)
+
+# Create collection from 2 magnets
+coll = cube + cylinder
+
+# Show styled objects
+magpy.show(coll, sphere, backend="matplotlib")
+```
+
+In addition, it is possible to modify individual style properties of all children, that cannot be set at Collection level, with the `set_children_styles` method. Non-matching properties, e.g. magnetization color for children that are currents, are simply ignored.
+
+```{code-cell} ipython3
+coll.set_children_styles(magnetization_color_south="blue")
+magpy.show(coll, sphere, backend="plotly")
+```
+
+The child-styles are individual style properties of the collection object and are not set as individual styles on each child-object. This means that when displayed individually with `show`, the above child-objects will have Magpylib default style.
+
+## Local style override
+
+Finally, it is possible to hand style input to the `show` function directly and locally override all style properties for this specific `show` output. Default or individual style attributes will not be modified. Such inputs must start with the `style` prefix and the object family specifier must be omitted. Naturally underscore magic is supported.
+
+In the following example the default `style.magnetization.show=True` is overridden locally, so that object colors become visible instead of magnetization colors in the Plotly backend.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+cube = magpy.magnet.Cuboid(
+    polarization=(1, 0, 0), dimension=(1, 1, 1)
+)
+cylinder = magpy.magnet.Cylinder(
+    polarization=(0, 1, 0), dimension=(1, 1), position=(2, 0, 0)
+)
+sphere = magpy.magnet.Sphere(
+    polarization=(0, 1, 1), diameter=1, position=(4, 0, 0)
+)
+
+# Show with local style override
+magpy.show(cube, cylinder, sphere, backend="plotly", style_magnetization_show=False)
+```
+
+(examples-list-of-styles)=
+
+## List of style properties
+
+```{code-cell} ipython3
+magpy.defaults.display.style.as_dict(flatten=True, separator=".")
+```
+
+(examples-own-3d-models)=
+## Custom 3D models
+
+Each Magpylib object has a default 3D representation that is displayed with `show`. It is possible to disable the default model and to provide Magpylib with a custom model.
+
+There are several reasons why this can be of interest. For example,  the integration of a [custom source](guide-docs-classes-custom-source) object that has its own geometry, to display a sensor in the form of a realistic package provided in CAD form, representation of a [Collection](guide-docs-classes-collections) as a parts holder, integration of environmental parts to the Magpylib 3D plotting scene, or simply highlighting an object when colors do not suffice.
+
+The default trace of a Magpylib object `obj` can simply be turned off using the individual style command `obj.style.model3d.showdefault = False`. A custom 3D model can be added using the function `obj.style.model3d.add_trace()`. The new trace is then stored in the `obj.style.model3d.data` property. This property is a list and it is possible to store multiple custom traces there. The default style is not included in this property. It is instead inherently stored in the Magpylib classes to enable visualization of the magnetization with a color scheme.
+
+The input of `add_trace()` must be a `magpylib.graphics.Trace3d` object, or a dictionary that contains all necessary information for generating a 3D model. Because different plotting libraries require different directives, traces might be bound to specific [backends](guide-graphic-backends). For example, a trace dictionary might contain all information for Matplotlib to generate a 3D model using the [plot_surface](https://matplotlib.org/stable/plot_types/3D/surface3d_simple.html) function.
+
+To enable visualization of custom objects with different graphic backends Magpylib implements a **generic backend**. Traces defined in the generic backend are translated to all other backends automatically. If a specific backend is used, the model will only appear when called with the corresponding backend.
+
+A trace-dictionary has the following keys:
+
+1. `'backend'`: `'generic'`, `'matplotlib'` or `'plotly'`
+2. `'constructor'`: name of the plotting constructor from the respective backend, e.g. plotly `'Mesh3d'` or matplotlib `'plot_surface'`
+3. `'args'`: default `None`, positional arguments handed to constructor
+4. `'kwargs'`: default `None`, keyword arguments handed to constructor
+5. `'coordsargs'`: tells Magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the `'generic'` backend and Plotly backend,  and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend.
+6. `'show'`: default `True`, toggle if this trace should be displayed
+7. `'scale'`: default 1, object geometric scaling factor
+8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate dynamic traces.
+
+The following example shows how a trace is constructed using the generic backend with the `Mesh3d` constructor. We create a `Sensor` object and replace its default 3d model by a tetrahedron.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Create trace dictionary
+trace_mesh3d = {
+    "backend": "generic",
+    "constructor": "Mesh3d",
+    "kwargs": {
+        "x": (1, 0, -1, 0),
+        "y": (-0.5, 1.2, -0.5, 0),
+        "z": (-0.5, -0.5, -0.5, 1),
+        "i": (0, 0, 0, 1),
+        "j": (1, 1, 2, 2),
+        "k": (2, 3, 3, 3),
+        #'opacity': 0.5,
+    },
+}
+
+# Create sensor
+sensor = magpy.Sensor(style_label="sensor")
+
+# Disable default model
+sensor.style.model3d.showdefault = False
+
+# Apply custom model
+sensor.style.model3d.add_trace(trace_mesh3d)
+
+# Show the system using different backends
+for backend in magpy.SUPPORTED_PLOTTING_BACKENDS:
+    print(f"Plotting backend: {backend!r}")
+    magpy.show(sensor, backend=backend)
+```
+
+As noted above, it is possible to have multiple user-defined traces that will be displayed at the same time. The following example continuation demonstrates this by adding two more traces using the `Scatter3d` constructor in the generic backend. In addition, it showns how to copy and manipulate `Trace3d` objects.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import copy
+import numpy as np
+
+# Generate trace and add to sensor
+ts = np.linspace(0, 2 * np.pi, 30)
+trace_scatter3d = {
+    "backend": "generic",
+    "constructor": "Scatter3d",
+    "kwargs": {
+        "x": 1.2*np.cos(ts),
+        "y": 1.2*np.sin(ts),
+        "z": np.zeros(30),
+        "mode": "lines",
+    },
+}
+sensor.style.model3d.add_trace(trace_scatter3d)
+
+# Generate new trace from Trace3d object
+trace2 = copy.deepcopy(sensor.style.model3d.data[1])
+trace2.kwargs["x"] = np.zeros(30)
+trace2.kwargs["z"] = 1.2*np.cos(ts)
+
+sensor.style.model3d.add_trace(trace2)
+
+# Show
+magpy.show(sensor)
+```
+
+### Matplotlib constructors
+
+The following examples show how to construct traces with `plot`, `plot_surface` and `plot_trisurf`:
+
+```{code-cell} ipython3
+import matplotlib.pyplot as plt
+import matplotlib.tri as mtri
+import numpy as np
+import magpylib as magpy
+
+# plot trace ###########################
+
+ts = np.linspace(-10, 10, 100)
+xs = np.cos(ts) / 100
+ys = np.sin(ts) / 100
+zs = ts / 20 / 100
+
+trace_plot = {
+    "backend": "matplotlib",
+    "constructor": "plot",
+    "args": (xs, ys, zs),
+    "kwargs": {"ls": "--", "lw": 2},
+}
+magnet = magpy.magnet.Cylinder(polarization=(0, 0, 1), dimension=(0.005, 0.01))
+magnet.style.model3d.add_trace(trace_plot)
+
+# plot_surface trace ###################
+
+u, v = np.mgrid[0 : 2 * np.pi : 30j, 0 : np.pi : 20j]
+xs = np.cos(u) * np.sin(v) / 100
+ys = np.sin(u) * np.sin(v) / 100
+zs = np.cos(v) / 100
+
+trace_surf = {
+    "backend": "matplotlib",
+    "constructor": "plot_surface",
+    "args": (xs, ys, zs),
+    "kwargs": {"cmap": plt.cm.YlGnBu_r},
+}
+ball = magpy.Collection(position=(-0.03, 0, 0))
+ball.style.model3d.add_trace(trace_surf)
+
+# plot_trisurf trace ###################
+
+u, v = np.mgrid[0 : 2 * np.pi : 50j, -0.5:0.5:10j]
+u, v = u.flatten(), v.flatten()
+
+xs = (1 + 0.5 * v * np.cos(u / 2.0)) * np.cos(u) / 100
+ys = (1 + 0.5 * v * np.cos(u / 2.0)) * np.sin(u) / 100
+zs = 0.5 * v * np.sin(u / 2.0) / 100
+
+tri = mtri.Triangulation(u, v)
+
+trace_trisurf = {
+    "backend": "matplotlib",
+    "constructor": "plot_trisurf",
+    "args": (xs, ys, zs),
+    "kwargs": {
+        "triangles": tri.triangles,
+        "cmap": plt.cm.coolwarm,
+    },
+}
+mobius = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(0.03, 0, 0))
+mobius.style.model3d.add_trace(trace_trisurf)
+
+magpy.show(magnet, ball, mobius, backend="matplotlib")
+```
+
+## Pre-defined 3D models
+
+Automatic trace generators are provided for several basic 3D models in `magpylib.graphics.model3d`. They can be used as follows,
+
+```{code-cell} ipython3
+from magpylib import Collection
+from magpylib.graphics import model3d
+
+# Prism trace ###################################
+trace_prism = model3d.make_Prism(
+    base=6,
+    diameter=2,
+    height=1,
+    position=(-3, 0, 0),
+)
+obj0 = Collection(style_label="Prism")
+obj0.style.model3d.add_trace(trace_prism)
+
+# Pyramid trace #################################
+trace_pyramid = model3d.make_Pyramid(
+    base=30,
+    diameter=2,
+    height=1,
+    position=(3, 0, 0),
+)
+obj1 = Collection(style_label="Pyramid")
+obj1.style.model3d.add_trace(trace_pyramid)
+
+# Cuboid trace ##################################
+trace_cuboid = model3d.make_Cuboid(
+    dimension=(2, 2, 2),
+    position=(0, 3, 0),
+)
+obj2 = Collection(style_label="Cuboid")
+obj2.style.model3d.add_trace(trace_cuboid)
+
+# Cylinder segment trace ########################
+trace_cylinder_segment = model3d.make_CylinderSegment(
+    dimension=(1, 2, 1, 140, 220),
+    position=(1, 0, -3),
+)
+obj3 = Collection(style_label="Cylinder Segment")
+obj3.style.model3d.add_trace(trace_cylinder_segment)
+
+# Ellipsoid trace ###############################
+trace_ellipsoid = model3d.make_Ellipsoid(
+    dimension=(2, 2, 2),
+    position=(0, 0, 3),
+)
+obj4 = Collection(style_label="Ellipsoid")
+obj4.style.model3d.add_trace(trace_ellipsoid)
+
+# Arrow trace ###################################
+trace_arrow = model3d.make_Arrow(
+    base=30,
+    diameter=0.6,
+    height=2,
+    position=(0, -3, 0),
+)
+obj5 = Collection(style_label="Arrow")
+obj5.style.model3d.add_trace(trace_arrow)
+
+obj0.show(obj1, obj2, obj3, obj4, obj5, backend="plotly")
+```
+
+((guide-docs-style-cad))=
+## Adding a CAD model
+
+The following code sample shows how a standard CAD model (*.stl file) can be transformed into a Magpylib `Trace3d` object.
+
+```{note}
+The code below requires installation of the `numpy-stl` package.
+```
+
+```{code-cell} ipython3
+import os
+import tempfile
+
+import numpy as np
+import requests
+from matplotlib.colors import to_hex
+from stl import mesh  # requires installation of numpy-stl
+
+import magpylib as magpy
+
+
+def bin_color_to_hex(x):
+    """transform binary rgb into hex color"""
+    sb = f"{x:015b}"[::-1]
+    r = int(sb[:5], base=2) / 31
+    g = int(sb[5:10], base=2) / 31
+    b = int(sb[10:15], base=2) / 31
+    return to_hex((r, g, b))
+
+
+def trace_from_stl(stl_file):
+    """
+    Generates a Magpylib 3D model trace dictionary from an *.stl file.
+    backend: 'matplotlib' or 'plotly'
+    """
+    # Load stl file
+    stl_mesh = mesh.Mesh.from_file(stl_file)
+
+    # Extract vertices and triangulation
+    p, q, r = stl_mesh.vectors.shape
+    vertices, ixr = np.unique(
+        stl_mesh.vectors.reshape(p * q, r), return_inverse=True, axis=0
+    )
+    i = np.take(ixr, [3 * k for k in range(p)])
+    j = np.take(ixr, [3 * k + 1 for k in range(p)])
+    k = np.take(ixr, [3 * k + 2 for k in range(p)])
+    x, y, z = vertices.T
+
+    # Create a generic backend trace
+    colors = stl_mesh.attr.flatten()
+    facecolor = np.array([bin_color_to_hex(c) for c in colors]).T
+    x, y, z = x / 1000, y / 1000, z / 1000  # mm->m
+    trace = {
+        "backend": "generic",
+        "constructor": "mesh3d",
+        "kwargs": dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor),
+    }
+    return trace
+
+
+# Load stl file from online resource
+url = "https://raw.githubusercontent.com/magpylib/magpylib-files/main/PG-SSO-3-2.stl"
+file = url.split("/")[-1]
+with tempfile.TemporaryDirectory() as temp:
+    fn = os.path.join(temp, file)
+    with open(fn, "wb") as f:
+        response = requests.get(url)
+        f.write(response.content)
+
+    # Create traces for both backends
+    trace = trace_from_stl(fn)
+
+# Create sensor and add CAD model
+sensor = magpy.Sensor(style_label="PG-SSO-3 package")
+sensor.style.model3d.add_trace(trace)
+
+# Create magnet and sensor path
+magnet = magpy.magnet.Cylinder(polarization=(0, 0, 1), dimension=(0.015, 0.02))
+sensor.position = np.linspace((-0.015, 0, 0.008), (-0.015, 0, -0.004), 21)
+sensor.rotate_from_angax(np.linspace(0, 180, 21), "z", anchor=0, start=0)
+
+# Display with matplotlib and plotly backends
+args = (sensor, magnet)
+kwargs = dict(style_path_frames=5)
+magpy.show(args, **kwargs, backend="matplotlib")
+magpy.show(args, **kwargs, backend="plotly")
+```
+
+```{code-cell} ipython3
+
+```
diff --git a/docs/_pages/user_guide/docs/docs_units_types.md b/docs/_pages/user_guide/docs/docs_units_types.md
new file mode 100644
index 000000000..c14ede192
--- /dev/null
+++ b/docs/_pages/user_guide/docs/docs_units_types.md
@@ -0,0 +1,50 @@
+# Units and Types
+
+(guide-docs-units)=
+## Units
+
+The important vacuum permeability $\mu_0$ is provided at the package top-level <span style="color: orange">**mu_0**</span>. It's value is not $4 \pi 10^{-7}$ since [the redefinition of the SI base units](https://en.wikipedia.org/wiki/2019_redefinition_of_the_SI_base_units), but a value close to it.
+
+For historical reasons Magpylib used non-SI units until Version 4. Starting with version 5 all inputs and outputs are SI-based.
+
+::::{grid} 3
+:::{grid-item}
+:columns: 1
+:::
+
+:::{grid-item}
+:columns: 10
+| PHYSICAL QUANTITY | MAGPYLIB PARAMETER | UNITS from v5| UNITS until v4|
+|:---:|:---:|:---:|:---:|
+| Magnetic Polarization $\vec{J}$  | `polarization`, `getJ()`      | **T**      | -        |
+| Magnetization $\vec{M}$          | `magnetization`, `getM()`     | **A/m**    | mT       |
+| Electric Current $i_0$           | `current`                     | **A**      | A        |
+| Magnetic Dipole Moment $\vec{m}$ | `moment`                      | **A·m²**   | mT·mm³   |
+| B-field $\vec{B}$                | `getB()`                      | **T**      | mT       |
+| H-field $\vec{H}$                | `getH()`                      | **A/m**    | kA/m     |
+| Length-inputs                    | `position`, `dimension`, `vertices`, ...  | **m**      | mm       |
+| Angle-inputs                     | `angle`, `dimension`, ...     | **°**      | °        |
+:::
+
+::::
+
+```{warning}
+Up to version 4, Magpylib was unfortunately contributing to the naming confusion in magnetism that is explained well [here](https://www.e-magnetica.pl/doku.php/confusion_between_b_and_h). The input `magnetization` in Magpylib < v5 was referring to the magnetic polarization (and not the magnetization), the difference being only in the physical unit. From version 5 onwards this is fixed.
+```
+
+```{note}
+The connection between the magnetic polarization J, the magnetization M and the material parameters of a real permanent magnet are shown in {ref}`examples-tutorial-modeling-magnets`.
+```
+
+(guide-docs-io-scale-invariance)=
+## Arbitrary unit Convention
+
+```{hint}
+All input and output units in Magpylib (version 5 and higher) are SI-based, see table above. However, for advanced use one should be aware that the analytical solutions are **scale invariant** - _"a magnet with 1 mm sides creates the same field at 1 mm distance as a magnet with 1 m sides at 1 m distance"_. The choice of length input unit is therefore not relevant, but it is critical to keep the same length unit for all inputs in one computation.
+
+In addition, `getB` returns the same unit as given by the `polarization` input. With polarization input in mT, `getB` will return mT as well. At the same time when the `magnetization` input is kA/m, then `getH` returns kA/m as well. The B/H-field outputs are related to a M/J-inputs via a factor of $µ_0$.
+```
+
+## Types
+
+Magpylib requires no special input format. All scalar types (`int`, `float`, ...) and vector types (`list`, `tuple`, `np.ndarray`, ... ) are accepted. Magpylib returns everything as `np.ndarray`.
diff --git a/docs/_pages/user_guide/examples/examples_app_coils.md b/docs/_pages/user_guide/examples/examples_app_coils.md
new file mode 100644
index 000000000..75f4ec61e
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_app_coils.md
@@ -0,0 +1,162 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-app-helmholtz)=
+
+# Coils
+
+In this example we show how to model air-coils, then combine two coils into a Helmholtz-pair and visualize the homogeneity of the resulting magnetic field. A nice explanation of coils and the magnetic field is given [here](https://www.nagwa.com/en/explainers/186157825721/#:~:text=The%20magnetic%20field%20strength%2C%20%F0%9D%90%B5,%EF%8A%AD%20T%E2%8B%85m%2FA.). With the code examples below you can easily compare Magpylib results to results presented in this tutorial.
+
+## Coil models
+
+**Model 1:** The coil consists of multiple windings, each of which can be modeled with a circular current loop which is realized by the `Circle` class. The individual windings are combined into a `Collection` which itself behaves like a single magnetic field source.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+coil1 = magpy.Collection()
+for z in np.linspace(-8, 8, 16):
+    winding = magpy.current.Circle(
+        current=100,
+        diameter=10,
+        position=(0,0,z),
+    )
+    coil1.add(winding)
+
+coil1.show()
+```
+
+**Model 2:** The coil is in reality more like a spiral, which can be modeled using the `Polyline` class. However, a good spiral approximation requires many small line segments, which makes the computation slower.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+ts = np.linspace(-8, 8, 300)
+vertices = np.c_[5*np.cos(ts*2*np.pi), 5*np.sin(ts*2*np.pi), ts]
+coil2 = magpy.current.Polyline(
+    current=100,
+    vertices=vertices
+)
+
+coil2.show()
+```
+
+**Model 3:** A [Helmholtz coil](https://en.wikipedia.org/wiki/Helmholtz_coil) is a device for producing a region of nearly uniform magnetic field. It consists of two coils on the same axis, carrying an equal electric current in the same direction. In classical layouts, the distance between the coils is similar to the coil radius.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+# Create a finite sized Helmholtz coil-pair
+coil1 = magpy.Collection()
+for z in np.linspace(-1, 1, 5):
+    for r in np.linspace(4, 5, 5):
+        winding = magpy.current.Circle(
+            current=10,
+            diameter=2*r,
+            position=(0,0,z),
+        )
+        coil1.add(winding)
+
+coil1.position = (0,0,5)
+coil2 = coil1.copy(position=(0,0,-5))
+
+helmholtz = magpy.Collection(coil1, coil2)
+
+helmholtz.show()
+```
+
+## Plotting the field
+
+Streamplot from Matplotlib is a powerful tool to outline the field lines. However, it must be understood that streamplot shows only a projection of the field onto the observation plane. All field components that point out of the plane become invisible. In out example we choose symmetry planes, where the perpendicular component is negligible.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+fig, ax = plt.subplots(1, 1, figsize=(6,5))
+
+# Compute field and plot the coil pair field on yz-grid
+grid = np.mgrid[0:0:1j, -13:13:20j, -13:13:20j].T[:,:,0]
+_, Y, Z = np.moveaxis(grid, 2, 0)
+
+B = magpy.getB(helmholtz, grid)
+_, By, Bz = np.moveaxis(B, 2, 0)
+
+Bamp = np.linalg.norm(B, axis=2)
+Bamp /= np.amax(Bamp)
+
+sp = ax.streamplot(Y, Z, By, Bz, density=2, color=Bamp,
+    linewidth=np.sqrt(Bamp)*3, cmap='coolwarm',
+)
+
+# Plot coil outline
+from matplotlib.patches import Rectangle
+for loc in [(4,4), (4,-6), (-6,4), (-6,-6)]:
+    ax.add_patch(Rectangle(loc, 2, 2, color='k', zorder=10))
+
+# Figure styling
+ax.set(
+    title='Magnetic field of Helmholtz',
+    xlabel='y-position (m)',
+    ylabel='z-position (m)',
+    aspect=1,
+)
+plt.colorbar(sp.lines, ax=ax, label='(T)')
+
+plt.tight_layout()
+plt.show()
+```
+
+## Helmholtz field homogeneity
+
+While the optimal solution is given by two current loops, real world applications must deal with finite sizes and limited construction space. Here Magpylib enables fast analysis of different possible geometries.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+fig, ax = plt.subplots(1, 1, figsize=(6,5))
+
+# Compute field of the coil pair on yz-grid
+grid = np.mgrid[0:0:1j, -3:3:20j, -3:3:20j].T[:,:,0]
+_, Y, Z = np.moveaxis(grid, 2, 0)
+
+B = helmholtz.getB(grid)
+
+# Field at center
+B0 = helmholtz.getB((0,0,0))
+B0amp = np.linalg.norm(B0)
+
+# Homogeneity error
+err = np.linalg.norm((B-B0)/B0amp, axis=2)
+
+# Plot error on grid
+sp = ax.contourf(Y, Z, err*100)
+
+# Figure styling
+ax.set(
+    title='Helmholtz homogeneity error',
+    xlabel='y-position (m)',
+    ylabel='z-position (m)',
+    aspect=1,
+)
+plt.colorbar(sp, ax=ax, label='(% of B0)')
+
+plt.tight_layout()
+plt.show()
+```
+
+Notice that in such finite sized arrangements the field is not very homogeneous.
diff --git a/docs/_pages/user_guide/examples/examples_app_end_of_shaft.md b/docs/_pages/user_guide/examples/examples_app_end_of_shaft.md
new file mode 100644
index 000000000..f0fa70179
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_app_end_of_shaft.md
@@ -0,0 +1,92 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-app-end-of-shaft)=
+
+# Magnetic Angle Sensor
+
+End of shaft angle sensing is a classic example for a magnetic position system. The goal is to determine the angular position of a rotating shaft. A magnet, typically a diametrically magnetized cylinder, is mounted at the end of the shaft. A 2D sensor is mounted below. When the shaft rotates the two sensor outputs will be $s_1=B_0 sin(\varphi)$ and $s_2=B_0 cos(\varphi)$, so that the angle is uniquely given by $\varphi = arctan_2(s_1,s_2)$.
+
+In the example below we show such a typical end-of-shaft system with a 2-pixel sensor, that is commonly used to eliminate external stray fields. In addition, we assume that the magnet is not perfectly mounted at the end of the shaft, but slightly displaced to the side, which results in a wobble motion. Such tolerances are easily implemented with Magpylib, they can be visualized and their influence on the sensor output signal can be tested quickly.
+
+```{code-cell} ipython3
+import numpy as np
+import plotly.express as px
+import magpylib as magpy
+import plotly.graph_objects as go
+
+# Create magnet
+magnet = magpy.magnet.Cylinder(
+    polarization=(1, 0, 0),
+    dimension=(.06, .02),
+    position=(0, 0, .015),
+    style_label="Magnet",
+    style_color=".7",
+)
+
+# Create shaft dummy with 3D model
+shaft = magpy.misc.CustomSource(
+    position=(0, 0, .07),
+    style_color=".7",
+    style_model3d_showdefault=False,
+    style_label="Shaft",
+)
+shaft_trace = magpy.graphics.model3d.make_Prism(
+    base=20,
+    diameter=.1,
+    height=.1,
+    opacity=0.3,
+)
+shaft.style.model3d.add_trace(shaft_trace)
+
+# Shaft rotation / magnet wobble motion
+displacement = .01
+angles = np.linspace(0, 360, 72)
+coll = magnet + shaft
+magnet.move((displacement, 0, 0))
+coll.rotate_from_angax(angles, "z", anchor=0, start=0)
+
+# Create sensor
+gap = .03
+sens = magpy.Sensor(
+    position=(0, 0, -gap),
+    pixel=[(.01, 0, 0), (-.01, 0, 0)],
+    style_pixel_size=0.5,
+    style_size=1.5,
+)
+
+# Show 3D animation of wobble motion
+fig1 = go.Figure()
+magpy.show(magnet, sens, shaft, animation=True, backend="plotly", canvas=fig1)
+fig1.update_layout(scene_camera_eye_z=-1.1)
+fig1.show()
+
+# Show sensor output in plotly
+fig2 = go.Figure()
+df = sens.getB(magnet, output="dataframe")
+df["angle (deg)"] = angles[df["path"]]
+
+fig2 = px.line(
+    df,
+    x="angle (deg)",
+    y=["Bx", "By"],
+    line_dash="pixel",
+    labels={"value": "Field (T)"},
+)
+fig2.show()
+```
+
+```{code-cell} ipython3
+
+```
diff --git a/docs/_pages/user_guide/examples/examples_app_halbach.md b/docs/_pages/user_guide/examples/examples_app_halbach.md
new file mode 100644
index 000000000..90e4f1096
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_app_halbach.md
@@ -0,0 +1,85 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-app-halbach)=
+
+# Halbach Magnets
+
+Magpylib is an excellent tool to create magnet assemblies. In this example we will show how to model Halbach magnets.
+
+```{note}
+In the following examples we make use of the [arbitrary unit convention](guide-docs-io-scale-invariance).
+```
+
+The original Halbach-magnetization describes a hollow cylinder with a polarization direction that rotates twice while going around the cylinder once. In reality such polarizations are difficult to fabricate. What is commonly done instead are "Discreete Halbach Arrays", which are magnet assemblies that approximate a Halbach magnetization.
+
+The following code creates a Discreete Halbach Cylinder generated from Cuboids:
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+N = 10
+angles = np.linspace(0, 360, N, endpoint=False)
+
+halbach = magpy.Collection()
+
+for a in angles:
+    cube = magpy.magnet.Cuboid(
+        dimension=(1,1,1),
+        polarization=(1,0,0),
+        position=(2.3,0,0)
+    )
+    cube.rotate_from_angax(a, 'z', anchor=0)
+    cube.rotate_from_angax(a, 'z')
+    halbach.add(cube)
+
+halbach.show(backend='plotly')
+```
+
+Next we compute and display the field on an xy-grid in the symmetry plane using the [matplotlib streamplot](examples-vis-mpl-streamplot) example.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+fig, ax = plt.subplots()
+
+# Compute and plot field on x-y grid
+grid = np.mgrid[-3.5:3.5:100j, -3.5:3.5:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+B = halbach.getB(grid)
+Bx, By, _ = np.moveaxis(B, 2, 0)
+Bamp = np.linalg.norm(B, axis=2)
+
+pc = ax.contourf(X, Y, Bamp, levels=50, cmap="coolwarm")
+ax.streamplot(X, Y, Bx, By, color="k", density=1.5, linewidth=1)
+
+# Add colorbar
+fig.colorbar(pc, ax=ax, label="|B|")
+
+# Figure styling
+ax.set(
+    xlabel="x-position",
+    ylabel="z-position",
+    aspect=1,
+)
+
+plt.show()
+```
+
+```{warning}
+Magpylib models magnets with perfect polarization. However, such magnets do not exist in reality due to fabrication tolerances and material response. While fabrication tolerances can be estimated easily, our [tutorial](examples-tutorial-modeling-magnets) explains how to deal with material response.
+```
diff --git a/docs/_pages/user_guide/examples/examples_app_scales.md b/docs/_pages/user_guide/examples/examples_app_scales.md
new file mode 100644
index 000000000..d07cd28cf
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_app_scales.md
@@ -0,0 +1,23 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-app-scales)=
+
+# Magnetic Scales
+
+In this example we will show how magnetic scales can be constructed with Magpylib for fast field computation.
+
+- reference to DS 91411 and 91479
+- ideal-typical scales
+- PWS experiment and PWS reference
diff --git a/docs/_pages/user_guide/examples/examples_force_floating.md b/docs/_pages/user_guide/examples/examples_force_floating.md
new file mode 100644
index 000000000..0a1aa452c
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_force_floating.md
@@ -0,0 +1,239 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-force-floating)=
+
+# Floating Magnets
+
+The examples here require installation of the [magpylib-force package](https://pypi.org/project/magpylib-force/). See also the [magpylib-force documentation](docs-magpylib-force).
+
+## Formalism
+
+With force and torque we can compute how a magnet moves in a magnetic field by solving the equations of motion,
+
+$$ \vec{F} = \dot{\vec{p}} \ \text{and} \ \vec{T} = \dot{\vec{L}}$$
+
+with force $\vec{F}$, momentum $\vec{p}$, torque $\vec{T}$ and angular momentum $\vec{L}$.
+
+We implement a first order semi-implicit Euler method that is used to compute [planetary motion](https://www.mgaillard.fr/2021/07/11/euler-integration.html). The algorithm splits the computation into small subsequent time-steps $\Delta t$, resulting in the following equations for the position $\vec{s}$, the velocity $\vec{v} = \dot{\vec{s}}$, the rotation angle $\vec{\varphi}$ and the angular velocity $\vec{\omega}$,
+
+$$\vec{v}(t+\Delta t) = \vec{v}(t) + \frac{\Delta t}{m} \vec{F}(t)$$
+
+$$\vec{s}(t+\Delta t) = \vec{s}(t) + \Delta t \cdot \vec{v} (t + \Delta t)$$
+
+$$\vec{\omega} (t + \Delta t) = \vec{ω}(t) + \Delta t \cdot J^{-1} \cdot \vec{T}(t)$$
+
+$$\vec{\varphi} (t + \Delta t) = \vec{\varphi}(t) + \Delta t \cdot \vec{\omega} (t + \Delta t) $$
+
+## Magnet and Coil
+
+In the following example we show an implementation of the proposed Euler scheme. A cubical magnet is accelerated by a current loop along the z-axis as show in the following sketch:
+
+```{figure} ../../../_static/images/examples_force_floating_coil-magnet.png
+:width: 40%
+:align: center
+:alt: Sketch of current loop and magnet.
+
+A cubical magnet is accelerated by a current loop.
+```
+
+Due to the symmetry of the problem there is no torque so we solve only the translation part of the equations of motion.
+
+In the beginning, the magnet is at rest and slightly displaced in z-direction from the center of the current loop. With time the magnet is accelerated and it's z-position is displayed in the figure below.
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+from magpylib_force import getFT
+from scipy.spatial.transform import Rotation as R
+
+def timestep(source, target, dt):
+    """
+    Apply translation-only Euler-sceme timestep to target.
+
+    Parameters:
+    -----------
+    source: Magpylib source object that generates the magnetic field
+
+    target: Magpylib target object viable for force computation. In addition,
+        the target object must have the following parameters describing
+        the motion state: v (velocity), m (mass)
+
+    dt: Euler scheme length of timestep
+    """
+
+    # compute force
+    F, _ = getFT(source, target)
+
+    # compute/set new velocity and position
+    target.v = target.v + dt/target.m * F
+    target.position = target.position + dt * target.v
+
+# Current loop that generates the field
+loop = magpy.current.Circle(diameter=10e-3, current=10)
+
+# Magnets which are accelerated in the loop-field
+cube1 = magpy.magnet.Cuboid(dimension=(5e-3,5e-3,5e-3), polarization=(0,0,1))
+cube1.meshing=(3,3,3)
+cube2 = cube1.copy(polarization=(0,0,-1))
+
+# Compute motion
+for cube, lab in zip([cube1, cube2], ["attractive", "repulsive"]):
+
+    # Set initial conditions
+    cube.m = 1e-3
+    cube.position=(0,0,3e-3)
+    cube.v = np.array([0,0,0])
+
+    # Compute timesteps
+    z = []
+    for _ in range(100):
+        z.append(cube.position[2]*1000)
+        timestep(loop, cube, dt=1e-3)
+
+    plt.plot(z, marker='.', label=lab)
+
+# Graphic styling
+plt.gca().legend()
+plt.gca().grid()
+plt.gca().set(
+    title="Magnet motion",
+    xlabel="timestep ()",
+    ylabel="z-Position (mm)",
+)
+plt.show()
+```
+
+The simulation is made with two magnets with opposing polarizations. In the "repulsive" case (orange) the magnetic moment of magnet and coil are anti-parallel and the magnet is simply pushed away from the coil in positive z-direction. In the "attractive" case  (blue) the moments are parallel to each other, and the magnet is accelerated towards the coil center. Due to inertia it then comes out on the other side, and is again attracted towards the center resulting in an oscillation.
+
+```{warning}
+This algorithm accumulates its error over time, which can be avoided by choosing smaller timesteps.
+```
+
+## Two-body problem
+
+In the following example we demonstrate a fully dynamic simulation with two magnetic bodies that rotate around each other, attracted towards each other by the magnetic force, and repelled by the centrifugal force.
+
+```{figure} ../../../_static/images/examples_force_floating_ringdown.png
+:width: 80%
+:align: center
+:alt: Sketch of two-magnet ringdown.
+
+Two freely moving magnets rotate around each other.
+```
+
+Contrary to the simple case above, we apply the Euler scheme also to the rotation degrees of freedom, as the magnets will change their orientation while they circle around each other.
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+from magpylib_force import getFT
+from scipy.spatial.transform import Rotation as R
+
+def timestep(source, target, dt):
+    """
+    Apply full Euler-sceme timestep to target.
+
+    Parameters:
+    -----------
+    source: Magpylib source object that generates the magnetic field
+
+    target: Magpylib target object viable for force computation. In addition,
+        the target object must have the following parameters describing
+        the motion state: v (velocity), m (mass), w (angular velocity),
+        I_inv (inverse inertial tensor)
+
+    dt: Euler scheme length of timestep
+    """
+    # compute force
+    F, T = getFT(source, target)
+
+    # compute/set new velocity and position
+    target.v = target.v + dt/target.m * F
+    target.position = target.position + dt * target.v
+
+    # compute/set new angular velocity and rotation angle
+    target.w = target.w + dt*target.orientation.apply(np.dot(target.I_inv, target.orientation.inv().apply(T)))
+    target.orientation = R.from_rotvec(dt*target.w)*target.orientation
+
+
+v0 = 5.18   # init velocity
+steps=505   # number of timesteps
+dt = 1e-2   # timstep size
+
+# Create the two magnets and set initial conditions
+sphere1 = magpy.magnet.Sphere(position=(5,0,0), diameter=1, polarization=(1,0,0))
+sphere1.meshing = 5
+sphere1.m = 2
+sphere1.v = np.array([0, v0, 0])
+sphere1.w = np.array([0, 0, 0])
+sphere1.I_inv = 1 * np.eye(3)
+
+sphere2 = sphere1.copy(position=(-5,0,0))
+sphere2.v = np.array([0,-v0, 0])
+
+# Solve equations of motion
+data = np.zeros((4,steps,3))
+for i in range(steps):
+    timestep(sphere1, sphere2, dt)
+    timestep(sphere2, sphere1, dt)
+
+    # Store results of each timestep
+    data[0,i] = sphere1.position
+    data[1,i] = sphere2.position
+    data[2,i] = sphere1.orientation.as_euler('xyz')
+    data[3,i] = sphere2.orientation.as_euler('xyz')
+
+# Plot results
+fig, (ax1,ax2) = plt.subplots(2,1,figsize=(10,5))
+
+for j,ls in enumerate(["-", "--"]):
+
+    # Plot positions
+    for i,a in enumerate("xyz"):
+        ax1.plot(data[j,:,i], label= a + str(j+1), ls=ls)
+
+    # Plot orientations
+    for i,a in enumerate(["phi", "psi", "theta"]):
+        ax2.plot(data[j+2,:,i], label= a + str(j+1), ls=ls)
+
+# Figure styling
+for ax in fig.axes:
+    ax.legend(fontsize=9, loc=6, facecolor='.8')
+    ax.grid()
+ax1.set(
+    title="Floating Magnet Ringdown",
+    ylabel="Positions (m)",
+)
+ax2.set(
+    ylabel="Orientations (rad)",
+    xlabel="timestep ()",
+)
+plt.tight_layout()
+plt.show()
+```
+
+In the figure one can see, that the initial velocity is chosen so that the magnets approach each other in a ringdown-like behavior. The magnets are magnetically locked towards each other - both always show the same orientation. However, given no initial angular velocity, the rotation angle is oscillating several times while circling once.
+
+A video is helpful in this case to understand what is going on. From the computation above, we build the following gif making use of this [export-animation](examples-vis-exporting-animations) tutorial.
+
+```{figure} ../../../_static/videos/example_force_floating_ringdown.gif
+:width: 60%
+:align: center
+:alt: animation of simulated magnet ringdown.
+
+Animation of above simulated magnet ringdown.
+```
diff --git a/docs/_pages/user_guide/examples/examples_force_force.md b/docs/_pages/user_guide/examples/examples_force_force.md
new file mode 100644
index 000000000..e17004ae8
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_force_force.md
@@ -0,0 +1,60 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-force-force)=
+
+# Magnetic Force and Torque
+
+The `magpylib-force` extension provides force and torque computation between Magpylib objects. A detailed description of the API and how the computation is achieved can be found in the [user guide](docs-force-computation).
+
+```{warning}
+[Scaling invariance](guide-docs-io-scale-invariance) does not hold for force computations! Be careful to provide the inputs in the correct units!
+```
+
+In the following example we show how to compute force and torque between two objects and how to represent it graphically.
+
+```{code-cell} ipython3
+import pyvista as pv
+import magpylib as magpy
+from magpylib_force import getFT
+
+# Source
+coil = magpy.current.Circle(position=(0,0,-.5), diameter=4, current=1000)
+coil.rotate_from_angax(angle=-20, axis='y')
+
+# Target
+cube = magpy.magnet.Cuboid(dimension=(.7,.7,.7), polarization=(0,0,1))
+cube.meshing = (10,10,10)
+
+# Compute force and torque
+F,T = getFT(coil, cube, anchor=None)
+
+print(f"Force (blue):    {[round(f) for f in F]} N")
+print(f"Torque (yellow): {[round(t) for t in T]} Nm")
+```
+
+Force and torque are really strong in this example, because the magnet and the coil are very large objects. With 0.7 m side length, the magnet has a Volume of ~1/3rd cubic meter :).
+
+```{code-cell} ipython3
+# Example continued from above
+
+# Plot force and torque in Pyvista with arrows
+pl = magpy.show(coil, cube, backend='pyvista', return_fig=True)
+arrowF = pv.Arrow(start=(0,0,0), direction=F)
+pl.add_mesh(arrowF, color="blue")
+arrowT = pv.Arrow(start=(0,0,0), direction=T)
+pl.add_mesh(arrowT, color="yellow")
+
+pl.show()
+```
diff --git a/docs/_pages/user_guide/examples/examples_force_holding_force.md b/docs/_pages/user_guide/examples/examples_force_holding_force.md
new file mode 100644
index 000000000..631f5326c
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_force_holding_force.md
@@ -0,0 +1,40 @@
+(examples-force-holding-force)=
+
+# Magnetic Holding Force
+
+The examples here require installation of the [magpylib-force package](https://pypi.org/project/magpylib-force/). See also the [magpylib-force documentation](docs-magpylib-force).
+
+With Magpylib-force it is possible to compute the holding force of a magnet attached magnetically to a soft-ferromagnetic plate. The "pull-force" is the opposing force that is required to detach the magnet from the surface.
+
+```{figure} ../../../_static/images/examples_force_holding_force.png
+:width: 40%
+:align: center
+:alt: Sketch of holding force.
+
+Sketch of holding force F that must be overcome to detach the magnet from a soft-magnetic plate.
+```
+
+For this we make use of the "magnetic mirror" effect, which is quite similar to the well-known electrostatic "mirror-charge" model. The magnetic field of a magnetic dipole moment that lies in front of a highly permeable surface is similar to the field of two dipole moments: the original one and one that is mirrored across the surface such that each "magnetic charge" that makes up the dipole moment is mirrored in both position and charge.
+
+The following example computes the holding force of a Cuboid magnet using the magnetic mirror effect.
+
+```{code-block} python
+import magpylib as magpy
+from magpylib_force import getFT
+
+# Target magnet
+m1 = magpy.magnet.Cuboid(
+    dimension=(5e-3, 2.5e-3, 1e-3),
+    polarization=(0, 0, 1.33),
+)
+m1.meshing = 100
+
+# Mirror magnet
+m2 = m1.copy(position=(0,0,1e-3))
+
+F,T = getFT(m2, m1)
+print(f"Holding Force: {round(F[2]*100)} g")
+# Holding Force: 349 g
+```
+
+Magnet dimensions and material from this example are taken from the [web](https://www.supermagnete.at/quadermagnete-neodym/quadermagnet-5mm-2.5mm-1.5mm_Q-05-2.5-1.5-HN). The remanence of N45 material lies within 1.32 and 1.36 T which corresponds to the polarization, see also the ["Modeling a real magnet"](examples-tutorial-modeling-magnets) tutorial. The computation confirms what is stated on the web-page, that the holding force of this magnet is about 350 g.
diff --git a/docs/_pages/user_guide/examples/examples_index.md b/docs/_pages/user_guide/examples/examples_index.md
new file mode 100644
index 000000000..9ccd92131
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_index.md
@@ -0,0 +1,287 @@
+(examples)=
+# Examples
+
+## Tutorials
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-tutorial-paths`
+:text-align: center
+:link: examples-tutorial-paths
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_tutorial_paths.png
+:::
+
+:::{grid-item-card} {ref}`examples-tutorial-field-computation`
+:text-align: center
+:link: examples-tutorial-field-computation
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_tutorial_field_computation.png
+:::
+
+:::{grid-item-card} {ref}`examples-tutorial-collection`
+:text-align: center
+:link: examples-tutorial-collection
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_tutorial_collection.png
+:::
+
+:::{grid-item-card} {ref}`examples-tutorial-custom`
+:text-align: center
+:link: examples-tutorial-custom
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_tutorial_custom.png
+:::
+
+:::{grid-item-card} {ref}`examples-tutorial-modeling-magnets`
+:text-align: center
+:link: examples-tutorial-modeling-magnets
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_tutorial_modeling_magnets.png
+:::
+
+::::
+
+
+## Visualizations
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-vis-magnet-colors`
+:text-align: center
+:link: examples-vis-magnet-colors
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_vis_magnet_colors.png
+:::
+
+:::{grid-item-card} {ref}`examples-vis-animations`
+:text-align: center
+:link: examples-vis-animations
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_vis_animations.png
+:::
+
+:::{grid-item-card} {ref}`examples-vis-subplots`
+:text-align: center
+:link: examples-vis-subplots
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_vis_subplots.png
+:::
+
+:::{grid-item-card} {ref}`examples-vis-mpl-streamplot`
+:text-align: center
+:link: examples-vis-mpl-streamplot
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_vis_mpl_streamplot.png
+:::
+
+:::{grid-item-card} {ref}`examples-vis-pv-streamlines`
+:text-align: center
+:link: examples-vis-pv-streamlines
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_vis_pv_streamlines.png
+:::
+
+
+
+::::
+
+(examples-complex-magnet-shapes)=
+## Complex Magnet Shapes
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-shapes-superpos`
+:text-align: center
+:link: examples-shapes-superpos
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_shapes_superpos.png
+:::
+
+:::{grid-item-card} {ref}`examples-shapes-convex-hull`
+:text-align: center
+:link: examples-shapes-convex-hull
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_shapes_convex_hull.png
+:::
+
+:::{grid-item-card} {ref}`examples-shapes-triangle`
+:text-align: center
+:link: examples-shapes-triangle
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_shapes_triangle.png
+:::
+
+:::{grid-item-card} {ref}`examples-shapes-pyvista`
+:text-align: center
+:link: examples-shapes-pyvista
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_shapes_pyvista.png
+:::
+
+:::{grid-item-card} {ref}`examples-shapes-cad`
+:text-align: center
+:link: examples-shapes-cad
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_shapes_cad.png
+:::
+
+::::
+
+
+## Miscellaneous
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-misc-compound`
+:text-align: center
+:link: examples-misc-compound
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_misc_compound.png
+:::
+
+:::{grid-item-card} {ref}`examples-misc-field-interpolation`
+:text-align: center
+:link: examples-misc-field-interpolation
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_misc_field_interpolation.png
+:::
+
+:::{grid-item-card} {ref}`examples-misc-inhom`
+:text-align: center
+:link: examples-misc-inhom
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_misc_inhom.png
+:::
+
+::::
+
+
+## Applications
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-app-end-of-shaft`
+:text-align: center
+:link: examples-app-end-of-shaft
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_app_end_of_shaft.png
+:::
+
+:::{grid-item-card} {ref}`examples-app-halbach`
+:text-align: center
+:link: examples-app-halbach
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_app_halbach.png
+:::
+
+:::{grid-item-card} {ref}`examples-app-helmholtz`
+:text-align: center
+:link: examples-app-helmholtz
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_app_helmholtz.png
+:::
+
+:::{grid-item-card} {ref}`examples-app-scales`
+:text-align: center
+:link: examples-app-scales
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_WIP.png
+:::
+
+::::
+
+## Magpylib-Force
+
+::::{grid} 2 3 4 4
+:gutter: 4
+
+:::{grid-item-card} {ref}`examples-force-force`
+:text-align: center
+:link: examples-force-force
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_force_force.png
+:::
+
+:::{grid-item-card} {ref}`examples-force-holding-force`
+:text-align: center
+:link: examples-force-holding-force
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_force_holding_force.png
+:::
+
+:::{grid-item-card} {ref}`examples-force-floating`
+:text-align: center
+:link: examples-force-floating
+:link-type: ref
+:link-alt: link to example
+:img-bottom: ../../../_static/images/examples_icon_force_floating.png
+:::
+
+::::
+
+
+```{toctree}
+:maxdepth: 2
+:hidden:
+
+examples_tutorial_paths.md
+examples_tutorial_field_computation.md
+examples_tutorial_collection.md
+examples_tutorial_custom.md
+examples_tutorial_modeling_magnets.md
+
+examples_vis_magnet_colors.md
+examples_vis_animations.md
+examples_vis_subplots.md
+examples_vis_mpl_streamplot.md
+examples_vis_pv_streamlines.md
+
+examples_shapes_superpos.md
+examples_shapes_convex_hull.md
+examples_shapes_triangle.md
+examples_shapes_pyvista.md
+examples_shapes_cad.md
+
+examples_misc_compound.md
+examples_misc_field_interpolation.md
+examples_misc_inhom.md
+
+examples_app_end_of_shaft.md
+examples_app_halbach.md
+examples_app_coils.md
+examples_app_scales.md
+
+examples_force_force.md
+examples_force_holding_force.md
+examples_force_floating.md
+```
diff --git a/docs/_pages/user_guide/examples/examples_misc_compound.md b/docs/_pages/user_guide/examples/examples_misc_compound.md
new file mode 100644
index 000000000..6f2616d6f
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_misc_compound.md
@@ -0,0 +1,206 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-misc-compound)=
+
+# Compounds
+
+The `Collection` class is a powerful tool for grouping and tracking object assemblies. However, it is often convenient to have assembly parameters themselves, like number of magnets, as variables. This is achieved by sub-classing `Collection`. We refer to such classes as "**Compounds**" and show how to seamlessly integrate them into Magpylib.
+
+## Subclassing collections
+
+In the following example we design a compound class `MagnetRing` which represents a ring of cuboid magnets with the parameter `cubes` that should refer to the number of magnets on the ring. The ring will automatically adjust its size when `cubes` is modified, including an additionally added encompassing 3D model that may, for example, represent a mechanical magnet holder.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+class MagnetRing(magpy.Collection):
+    """ A ring of cuboid magnets
+
+    Parameters
+    ----------
+    cubes: int, default=6
+        Number of cubes on ring.
+    """
+
+    def __init__(self, cubes=6, **kwargs):
+        super().__init__(**kwargs)             # hand over style args
+        self._update(cubes)
+
+    @property
+    def cubes(self):
+        """Number of cubes"""
+        return self._cubes
+
+    @cubes.setter
+    def cubes(self, inp):
+        """Set cubes"""
+        self._update(inp)
+
+    def _update(self, cubes):
+        """Update MagnetRing instance"""
+        self._cubes = cubes
+        ring_radius = cubes/300
+
+        # Store existing path
+        pos_temp = self.position
+        ori_temp = self.orientation
+
+        # Clean up old object properties
+        self.reset_path()
+        self.children = []
+        self.style.model3d.data.clear()
+
+        # Add children
+        for i in range(cubes):
+            child = magpy.magnet.Cuboid(
+                polarization=(0,0,1),
+                dimension=(.01,.01,.01),
+                position=(ring_radius,0,0)
+            )
+            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
+            self.add(child)
+
+        # Re-apply path
+        self.position = pos_temp
+        self.orientation = ori_temp
+
+        # Add parameter-dependent 3d trace
+        trace = magpy.graphics.model3d.make_CylinderSegment(
+            dimension=(ring_radius-.006, ring_radius+.006, 0.011, 0, 360),
+            vert=150,
+            opacity=0.2,
+        )
+        self.style.model3d.add_trace(trace)
+
+        return self
+```
+
+This new `MagnetRing` class seamlessly integrates into Magpylib and makes use of the position and orientation interface, field computation and graphic display.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+# Add a sensor
+sensor = magpy.Sensor(position=(0, 0, 0))
+
+# Create a MagnetRing object
+ring = MagnetRing()
+
+# Move MagnetRing around
+ring.rotate_from_angax(angle=45, axis='x')
+
+# Compute field
+print(f"B-field at sensor → {ring.getB(sensor).round(2)}")
+
+# Display graphically
+magpy.show(ring, sensor, backend='plotly')
+```
+
+The `MagnetRing` parameter `cubes` can be modified dynamically:
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(3)}")
+
+ring.cubes = 10
+
+print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(3)}")
+
+magpy.show(ring, sensor, backend='plotly')
+```
+
+## Postponed trace construction
+
+In the above example, the trace is constructed in `_update`, every time the parameter `cubes` is modified. This can lead to an unwanted computational overhead, especially as the construction is only necessary for graphical representation.
+
+To make our compounds ready for heavy computation, while retaining Magpylib graphic possibilities, it is possible to provide a trace which will only be constructed when `show` is called. The following modification of the above example demonstrates this:
+
+```{code-cell} ipython3
+class MagnetRingAdv(magpy.Collection):
+    """ A ring of cuboid magnets
+
+    Parameters
+    ----------
+    cubes: int, default=6
+        Number of cubes on ring.
+    """
+
+    def __init__(self, cubes=6, **style_kwargs):
+        super().__init__(**style_kwargs)             # hand over style args
+        self._update(cubes)
+
+        # Hand trace over as callable
+        self.style.model3d.add_trace(self._custom_trace3d)
+
+    @property
+    def cubes(self):
+        """Number of cubes"""
+        return self._cubes
+
+    @cubes.setter
+    def cubes(self, inp):
+        """Set cubes"""
+        self._update(inp)
+
+    def _update(self, cubes):
+        """Update MagnetRing instance"""
+        self._cubes = cubes
+        ring_radius = cubes/300
+
+        # Store existing path and reset
+        pos_temp = self.position
+        ori_temp = self.orientation
+        self.reset_path()
+
+        # Add children
+        for i in range(cubes):
+            child = magpy.magnet.Cuboid(
+                polarization=(0,0,1),
+                dimension=(.01,.01,.01),
+                position=(ring_radius,0,0)
+            )
+            child.rotate_from_angax(360/cubes*i, 'z', anchor=0)
+            self.add(child)
+
+        # Re-apply path
+        self.position = pos_temp
+        self.orientation = ori_temp
+
+        return self
+
+    def _custom_trace3d(self):
+        """ creates a parameter-dependent 3d model"""
+        trace = magpy.graphics.model3d.make_CylinderSegment(
+            dimension=(self.cubes/300-.006, self.cubes/300+0.006, 0.011, 0, 360),
+            vert=150,
+            opacity=0.2,
+        )
+        return trace
+```
+
+We have removed the trace construction from the `_update` method, and instead provided `_custom_trace3d` as a callable.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+ring0 = MagnetRing()
+%time for _ in range(10): ring0.cubes=10
+
+ring1 = MagnetRingAdv()
+%time for _ in range(10): ring1.cubes=10
+```
+
+This example is not very impressive because the provided trace is not very heavy.
diff --git a/docs/_pages/user_guide/examples/examples_misc_field_interpolation.md b/docs/_pages/user_guide/examples/examples_misc_field_interpolation.md
new file mode 100644
index 000000000..63a779352
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_misc_field_interpolation.md
@@ -0,0 +1,164 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-misc-field-interpolation)=
+
+# Field Interpolation
+
+There are several reasons for working with field interpolations rather than computing the field on demand.
+
+1. Very large grids take a lot of time to compute, even with Magpylib.
+2. The field might not be accessible through Magpylib, e.g. when demagnetization is included, but it can be computed with a 3rd party FE tool or is the result of an experiment.
+
+Combining field interpolation and `CustomSource` enables integration of pre-computed solutions. In the following example we show how this can be done.
+
+## Interpolation
+
+We start by defining a 3D vector-field interpolation function relying on Scipy `RegularGridInterpolator`.
+
+```{code-cell} ipython3
+import numpy as np
+from scipy.interpolate import RegularGridInterpolator
+import magpylib as magpy
+
+def interpolation(observer, data, method="linear", bounds_error=False, fill_value=np.nan):
+    """ Creates a 3D-vector field interpolation from regular grid data
+
+    Parameters
+    ----------
+    observer: ndarray, shape (n,3)
+        Array of n position vectors (x,y,z).
+
+    data: ndarray, shape (n,3)
+        Array of corresponding n interpolation data vectors.
+
+    method : str, optional
+        The method of interpolation to perform. Supported are "linear" and
+        "nearest". Default is "linear".
+
+    bounds_error : bool, optional
+        If True, when interpolated values are requested outside of the
+        domain of the input data, a ValueError is raised. If False,
+        then `fill_value` is returned.
+
+    fill_value : number, optional
+        Value returned when points outside the interpolation domain are
+        sampled.
+
+    Returns
+    -------
+        callable: interpolating function for field values
+    """
+
+    # Condition input
+    X, Y, Z = [np.unique(o) for o in observer.T]
+    nx, ny, nz = len(X), len(Y), len(Z)
+
+    # Construct interpolations with RegularGridInterpolator for each field component
+    rgi = [
+        RegularGridInterpolator(
+            points=(X, Y, Z),
+            values=d.reshape(nx, ny, nz),
+            bounds_error=bounds_error,
+            fill_value=fill_value,
+            method=method,
+        )
+        for d in data.T]
+
+    # Define field_func usable by Magpylib that returns the interpolation
+    def field_func(field, observers):
+        return np.array([f(observers) for f in rgi]).T
+    return field_func
+```
+
+## CustomSource with Interpolation Field
+
+In the second step we create a custom source with an interpolated field `field_func` input. The data for the interpolation is generated from the Magpylib `Cuboid` field, which makes it easy to verify the approach afterwards. To the custom source a nice 3D model is added that makes it possible to display it and the cuboid at the same time.
+
+```{code-cell} ipython3
+# Create data for interpolation
+cube = magpy.magnet.Cuboid(polarization=(0,0,1), dimension=(.02,.02,.02))
+ts = np.linspace(-.07, .07, 21)
+grid = np.array([(x,y,z) for x in ts for y in ts for z in ts])
+data = cube.getB(grid)
+
+# Create custom source with interpolation field
+custom = magpy.misc.CustomSource(
+    field_func=interpolation(grid, data),
+    style_label="custom",
+)
+
+# Add nice 3D model (dashed outline) to custom source
+xs = 0.011*np.array([-1, -1,  1,  1, -1, -1, -1, -1, -1,  1,  1,  1,  1, 1,  1, -1])
+ys = 0.011*np.array([-1,  1,  1, -1, -1, -1,  1,  1,  1,  1,  1,  1, -1, -1, -1, -1])
+zs = 0.011*np.array([-1, -1, -1, -1, -1,  1,  1, -1,  1,  1, -1,  1,  1, -1,  1,  1])
+trace = dict(
+    backend='matplotlib',
+    constructor='plot',
+    args=(xs, ys, zs),
+    kwargs={'ls':'--', 'marker':'', 'lw':1, 'color':'k'},
+)
+custom.style.model3d.add_trace(trace)
+custom.style.model3d.showdefault = False
+
+# Display custom
+magpy.show(custom, cube, zoom=1, backend="matplotlib")
+```
+
+## Testing Interpolation Accuracy
+
+Finally, we compare the "exact" field of the cuboid source with the interpolated field of the custom source. For this purpose, a sensor is added and a generic rotation is applied to the sources. Naturally there is some error that can be reduced by increasing the interpolation grid finesse.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+
+# Modify orientation of cube and custom
+for src in [cube, custom]:
+    src.rotate_from_angax(angle=45, axis=(1,1,1))
+
+# Add a sensor for testing
+sensor = magpy.Sensor(position=(-.05,0,0))
+angs = np.linspace(3,150,49)
+sensor.rotate_from_angax(angle=angs, axis="y", anchor=0)
+
+# Display system graphically
+magpy.show(cube, custom, sensor, backend="matplotlib")
+
+# Create Matplotlib plotting axis
+ax = plt.subplot()
+
+# Compute and plot fields
+B_cube = cube.getB(sensor)
+B_custom = custom.getB(sensor)
+for i,lab in enumerate(["Bx", "By", "Bz"]):
+    ax.plot(B_cube[:,i], ls="-", label=lab)
+    ax.plot(B_custom[:,i], ls="--", color="k")
+
+# Matplotlib figure styling
+ax.legend()
+ax.grid(color=".9")
+ax.set(
+    title="Field at sensor - real (solid), interpolated (dashed)",
+    xlabel="sensor rotation angle (deg)",
+    ylabel="(T)",
+)
+
+plt.show()
+```
+
+```{code-cell} ipython3
+
+```
diff --git a/docs/_pages/user_guide/examples/examples_misc_inhom.md b/docs/_pages/user_guide/examples/examples_misc_inhom.md
new file mode 100644
index 000000000..a65f3ecd2
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_misc_inhom.md
@@ -0,0 +1,141 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-misc-inhom)=
+
+# Inhomogeneous Magnetization
+
+The analytical expressions implemented in Magpylib treat only simple homogeneous polarizations. When dealing with high-grade materials that are magnetized in homogeneous fields this is a good approximation. However, there are many cases where such a homogeneous model is not justified. The tutorial {ref}`examples-tutorial-modeling-magnets` and the user-guide {ref}`guide-physics-demag` provide some insights on this topic.
+
+Here we show how to deal with inhomogeneous polarization based on a commonly misunderstood example of a cylindrical quadrupol magnet. While graphical representations of such magnets usually depict only four poles, see {ref}`examples-vis-magnet-colors`, such magnets exhibit complex polarization given by the magnetization device that is used to magnetize them.
+
+The following code shows how the field of such a magnetization device would look like and what magnetization field it produces. To realize a Cylindrical Quadrupole magnert there are four coils with ferromagnetic cores involved, arranged in circle around the magnet. In the example, coils and cores are modeled by Cuboid magnets.
+
+```{note}
+While Magpylib uses SI units by default, in this example we make use of [scaling invariance](guide-docs-io-scale-invariance) and consider arbitrary input length units. For this example millimeters are sensible.
+```
+
+```{code-cell} ipython
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+
+# Create figure with 2D and 3D canvas
+fig = plt.figure(figsize=(8, 4))
+ax1 = fig.add_subplot(121, projection='3d')
+ax2 = fig.add_subplot(122)
+
+# Model of magnetization tool
+tool1 = magpy.magnet.Cuboid(
+    dimension=(5, 3, 3),
+    polarization=(1, 0, 0),
+    position=(9, 0, 0)
+).rotate_from_angax(50, 'z', anchor=0)
+tool2 = tool1.copy(polarization=(-1,0,0)).rotate_from_angax(-100, 'z', 0)
+tool3 = tool1.copy().rotate_from_angax(180, 'z', 0)
+tool4 = tool2.copy().rotate_from_angax(180, 'z', 0)
+tool = magpy.Collection(tool1, tool2, tool3, tool4)
+
+# Model of Quadrupole Cylinder
+cyl = magpy.magnet.CylinderSegment(
+    dimension=(2, 5, 1, 0, 360),
+    polarization=(0, 0, 0),
+    style_magnetization_show=False,
+)
+
+# Plot 3D model on ax1
+magpy.show(cyl, tool, canvas=ax1, style_legend_show=False, style_magnetization_mode="color")
+ax1.view_init(90, -90)
+
+# Compute and plot tool-field on grid
+grid = np.mgrid[-6:6:50j, -6:6:50j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+B = tool.getB(grid)
+Bx, By, Bz = np.moveaxis(B, 2, 0)
+
+ax2.streamplot(X, Y, Bx, By,
+    color=np.linalg.norm(B, axis=2),
+    cmap='autumn',
+    density=1.5,
+    linewidth=1,
+)
+
+# Outline magnet boundary
+ts = np.linspace(0,2*np.pi,200)
+ax2.plot(2*np.sin(ts), 2*np.cos(ts), color='k', lw=2)
+ax2.plot(5*np.sin(ts), 5*np.cos(ts), color='k', lw=2)
+
+# Plot styling
+ax2.set(
+    title="B-field in xy-plane",
+    xlabel="x-position",
+    ylabel="y-position",
+    aspect=1,
+)
+
+plt.tight_layout()
+plt.show()
+```
+
+It can be assumed that the polarization that is written into the unmagnetized Cylinder will, in a lowest order approximation, follow the magnetic field generated by the magnetization tool. To create a Cylinder magnet with such a polarization pattern we apply the [superposition principle](examples-shapes-superpos) and approximate the inhomogneous polarization as the sum of multiple small homogeneous cells using the `CylinderSegment` class. Splitting up the Cylinder into many cells is easily done by hand, but for practicality we make use of the [magpylib-material-response](https://pypi.org/project/magpylib-material-response/) package which provides an excellent function for this purpose.
+
+```{code-cell} ipython
+# Continuation from above - ensure previous code is executed
+
+from magpylib_material_response.meshing import mesh_Cylinder
+
+# Create figure with 2D and 3D canvas
+fig = plt.figure(figsize=(8, 4))
+ax1 = fig.add_subplot(121, projection='3d')
+ax2 = fig.add_subplot(122)
+
+# Show Cylinder cells
+mesh = mesh_Cylinder(cyl,30)
+magpy.show(*mesh, canvas=ax1, style_magnetization_show=False)
+
+# Apply polarization
+for m in mesh:
+    Btool = tool.getB(m.barycenter)
+    m.polarization = Btool/np.linalg.norm(Btool)
+
+# Compute and plot polarization
+J = mesh.getJ(grid)
+J[np.linalg.norm(J, axis=2) == 0] = np.nan # remove J=0 from plot
+Jx, Jy, _ = np.moveaxis(J, 2, 0)
+
+Jangle = np.arctan2(Jx, Jy)
+
+ax2.contourf(X, Y, Jangle, cmap="rainbow", levels=30)
+ax2.streamplot(X, Y, Jx, Jy, color='k')
+
+# Outline magnet boundary
+ts = np.linspace(0,2*np.pi,200)
+ax2.plot(2*np.sin(ts), 2*np.cos(ts), color='k', lw=2)
+ax2.plot(5*np.sin(ts), 5*np.cos(ts), color='k', lw=2)
+
+# Plot styling
+ax2.set(
+    title="Polarization J in xy-plane",
+    xlabel="x-position",
+    ylabel="y-position",
+    aspect=1,
+)
+plt.tight_layout()
+plt.show()
+```
+
+The color on the right-hand-side corresponds to the angle of orientation of the material polarization. Increasing the mesh finesse will improve the approximation but slow down any field computation at the same time.
+
+**What is the purpose of this example ?** In addition to demonstrating how inhomogeneous polarizations can be modeled, this example should raise awareness that many magnets can look simple on a data sheet (color pattern) but may have an inhomogeneous, complex, unknown polarization distribution. It goes without saying that the magnetic field generated by such a magnet, for example in an angle sensing application, will depend strongly on the polarization.
diff --git a/docs/_pages/user_guide/examples/examples_shapes_cad.md b/docs/_pages/user_guide/examples/examples_shapes_cad.md
new file mode 100644
index 000000000..41615f458
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_shapes_cad.md
@@ -0,0 +1,43 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.13.7
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-shapes-cad)=
+
+# Magnets from CAD
+
+The easiest way to create complex magnet shapes from CAD files is through Pyvista using the [TriangularMesh class](docu-magpylib-api-trimesh). Pyvista supports *.stl files, and any open CAD file format is easily transformed to stl.
+
+```{warning}
+CAD files might include many Triangles, especially when dealing with round sides and edges, that do not significantly contribute to the field and will slow down the Magpylib computation.
+```
+
+```{code-cell} ipython3
+import pyvista as pv
+from magpylib.magnet import TriangularMesh
+
+# Import *.stl file with Pyvista
+mesh = pv.read("logo.stl")
+
+# Transform into Magpylib magnet
+magnet = TriangularMesh.from_pyvista(
+    polydata=mesh,
+    polarization=(1,-1,0),
+    check_disconnected=False,
+)
+magnet.show(backend="plotly")
+```
+
+```{hint}
+A quick way to work with cad files, especially transforming 2D *.svg to 3D *.stl, is provided by [Tinkercad](https://www.tinkercad.com).
+```
diff --git a/docs/_pages/user_guide/examples/examples_shapes_convex_hull.md b/docs/_pages/user_guide/examples/examples_shapes_convex_hull.md
new file mode 100644
index 000000000..69f493834
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_shapes_convex_hull.md
@@ -0,0 +1,45 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-shapes-convex-hull)=
+
+# Convex Hull
+
+In geometry the convex hull of a point cloud is the smallest convex shape that contains all points, see [Wikipedia](https://en.wikipedia.org/wiki/Convex_hull).
+
+Magpylib offers construction of convex hull magnets by combining the `magpylib.magnets.TriangularMesh` and the [scipy.spatial.ConvexHull](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.ConvexHull.html) classes via the class method `from_ConvexHull`. Note, that the Scipy method does not guarantee correct face orientations if `reorient_faces` is disabled.
+
+## Pyramid magnet
+
+This is the fastest way to construct a pyramid magnet.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Create pyramid magnet
+points = np.array([(-2, -2, 0), (-2, 2, 0), (2, -2, 0), (2, 2, 0), (0, 0, 3)]) / 100
+tmesh_pyramid = magpy.magnet.TriangularMesh.from_ConvexHull(
+    polarization=(0, 0, 1),
+    points=points,
+    style_label="Pyramid Magnet",
+)
+
+# Display graphically
+tmesh_pyramid.show(backend="plotly")
+```
+
+```{code-cell} ipython3
+
+```
diff --git a/docs/_pages/user_guide/examples/examples_shapes_pyvista.md b/docs/_pages/user_guide/examples/examples_shapes_pyvista.md
new file mode 100644
index 000000000..d4bf7763d
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_shapes_pyvista.md
@@ -0,0 +1,118 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-shapes-pyvista)=
+
+# Pyvista Bodies
+
+[Pyvista](https://docs.pyvista.org/version/stable/) is a powerful open-source tool for the creation and visualization of meshes. Pyvista `PolyData` objects can be directly transformed into Magpylib `TriangularMesh` magnets via the classmethod `from_pyvista`.
+
+```{note}
+The Pyvista library used in the following examples is not automatically installed with Magpylib. A Pyvista installation guide is found [here](https://docs.pyvista.org/getting-started/installation.html).
+```
+
+## Dodecahedron Magnet
+
+In this example a Magpylib magnet is generated directly from a Pyvista body.
+
+```{code-cell} ipython3
+import numpy as np
+import pyvista as pv
+import magpylib as magpy
+
+# Create a simple pyvista PolyData object
+dodec_mesh = pv.Dodecahedron(radius=.01)
+
+dodec = magpy.magnet.TriangularMesh.from_pyvista(
+    polarization=(0, 0, .1),
+    polydata=dodec_mesh,
+)
+
+# Add a sensor with path
+sens = magpy.Sensor(position=np.linspace((-2,0,1), (2,0,1), 100)/100)
+
+# Show system and field
+with magpy.show_context(dodec, sens, backend='plotly') as s:
+    s.show(col=1)
+    s.show(col=2, output=['Bx', 'Bz'])
+```
+
+## Boolean operations with Pyvista
+
+With Pyvista it is possible to build complex shapes with boolean geometric operations. However, such operations often result in open and disconnected meshes that require some refinement to produce solid magnets. The following example demonstrates the problem, how to analyze and fix it.
+
+```{code-cell} ipython3
+import pyvista as pv
+import magpylib as magpy
+
+# Create a complex pyvista PolyData object using a boolean operation
+sphere = pv.Sphere(radius=0.006)
+cube = pv.Cube(x_length=.01, y_length=.01, z_length=.01).triangulate()
+obj = cube.boolean_difference(sphere)
+
+# Construct magnet from PolyData object and ignore check results
+magnet = magpy.magnet.TriangularMesh.from_pyvista(
+    polarization=(0, 0, .1),
+    polydata=obj,
+    check_disconnected="ignore",
+    check_open="ignore",
+    reorient_faces="ignore",
+    style_label="magnet",
+)
+
+print(f'mesh status open: {magnet.status_open}')
+print(f'mesh status disconnected: {magnet.status_disconnected}')
+print(f"mesh status self-intersecting: {magnet.status_selfintersecting}")
+print(f'mesh status reoriented: {magnet.status_reoriented}')
+
+magnet.show(
+    backend="plotly",
+    style_mesh_open_show=True,
+    style_mesh_disconnected_show=True,
+)
+```
+
+The result cannot be used for magnetic field computation. Even if all faces were present, the reorient-faces algorithm would fail when these faces are disconnected. Such problems can be fixed by
+
+1. giving Pyvista a finer mesh to work with from the start
+2. Pyvista mesh cleaning (merge duplicate points, remove unused points, remove degenerate faces)
+
+The following code produces a clean magnet.
+
+```{code-cell} ipython3
+import pyvista as pv
+import magpylib as magpy
+
+# Create a complex Pyvista PolyData object using a boolean operation. Start with
+# finer mesh and clean after operation
+sphere = pv.Sphere(radius=0.6)
+cube = pv.Cube().triangulate().subdivide(2)
+obj = cube.boolean_difference(sphere)
+obj = obj.clean()
+obj = obj.scale([1e-2]*3)
+
+# Construct magnet from PolyData object
+magnet = magpy.magnet.TriangularMesh.from_pyvista(
+    polarization=(0, 0, .1),
+    polydata=obj,
+    style_label="magnet",
+)
+
+print(f'mesh status open: {magnet.status_open}')
+print(f'mesh status disconnected: {magnet.status_disconnected}')
+print(f"mesh status self-intersecting: {magnet.status_selfintersecting}")
+print(f'mesh status reoriented: {magnet.status_reoriented}')
+
+magnet.show(backend="plotly")
+```
diff --git a/docs/_pages/user_guide/examples/examples_shapes_superpos.md b/docs/_pages/user_guide/examples/examples_shapes_superpos.md
new file mode 100644
index 000000000..309b2abad
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_shapes_superpos.md
@@ -0,0 +1,195 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-shapes-superpos)=
+
+# Superposition
+
+The [superposition principle](https://en.wikipedia.org/wiki/Superposition_principle) states that the net response caused by two or more stimuli is the sum of the responses caused by each stimulus individually. This principle holds in magnetostatics when there is no material response, and simply states that the total field created by multiple magnets and currents is the sum of the individual fields.
+
+When two magnets overlap geometrically, the magnetization in the overlap region is given by the vector sum of the two individual magnetizations. This enables two geometric operations,
+
+:::::{grid} 1 2 2 2
+:gutter: 4
+
+::::{grid-item-card} Union
+:img-bottom: ../../../_static/images/docu_field_superpos_union.png
+:shadow: None
+Build complex forms by aligning base shapes (no overlap) with each other with similar magnetization vector.
+::::
+
+::::{grid-item-card} Cut-Out
+:img-bottom: ../../../_static/images/docu_field_superpos_cutout.png
+:shadow: None
+When two objects with opposing magnetization vectors of similar amplitude overlap, they will just cancel in the overlap region. This enables geometric cut-out operations.
+::::
+:::::
+
+
+## Union operation
+
+Geometric union by superposition is demonstrated in the following example where a wedge-shaped magnet with a round back is constructed from three base-forms: a CylinderSegment, a Cuboid and a TriangularMesh.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Create three magnet parts with similar polarization
+pt1 = magpy.magnet.CylinderSegment(
+    polarization=(0.5, 0, 0),
+    dimension=(0, 0.04, 0.02, 90, 270),
+)
+pt2 = magpy.magnet.Cuboid(
+    polarization=(0.5, 0, 0), dimension=(0.02, 0.08, 0.02), position=(0.01, 0, 0)
+)
+pt3 = magpy.magnet.TriangularMesh.from_ConvexHull(
+    polarization=(0.5, 0, 0),
+    points=np.array(
+        [(2, 4, -1), (2, 4, 1), (2, -4, -1), (2, -4, 1), (6, 0, 1), (6, 0, -1)]
+    )
+    / 100,
+)
+
+# Combine parts in a Collection
+magnet = magpy.Collection(pt1, pt2, pt3)
+
+# Add a sensor with path
+sensor = magpy.Sensor()
+sensor.position = np.linspace((7, -10, 0), (7, 10, 0), 100) / 100
+
+# Plot
+with magpy.show_context(magnet, sensor, backend="plotly", style_legend_show=False) as s:
+    s.show(col=1)
+    s.show(output="B", col=2)
+```
+
+## Cut-out operation
+
+When two objects with opposing magnetization vectors of similar amplitude overlap, they will just cancel in the overlap region. This enables geometric cut-out operations. In the following example we construct an exact hollow cylinder solution from two concentric cylinder shapes with opposite magnetizations and compare the result to the `CylinderSegment` class solution.
+
+Here the `getM` and `getJ` functions come in handy. They allow us to see the magnetization distribution that is the result of the superposition.
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+
+fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(12, 5))
+
+# Create ring with cut-out
+inner = magpy.magnet.Cylinder(polarization=(0, 0, -1), dimension=(4, 5))
+outer = magpy.magnet.Cylinder(polarization=(0, 0, 1), dimension=(6, 5))
+ring0 = inner + outer
+
+# Compute and plot Magnetization in xy-plane
+grid = np.mgrid[-4:4:100j, -4:4:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+M = np.linalg.norm(ring0.getM(grid), axis=2)
+ax1.contourf(X, Y, M, cmap=plt.cm.hot_r)
+
+# Compute and plot Magnetization in xz-plane
+grid = np.mgrid[-4:4:100j, 0:0:1j, -4:4:100j].T[:,0]
+X, _, Z = np.moveaxis(grid, 2, 0)
+
+M = np.linalg.norm(ring0.getM(grid), axis=2)
+ax2.contourf(X, Z, M, cmap=plt.cm.hot_r)
+
+# Plot styling
+ax1.set(
+    title="|M| in xy-plane",
+    xlabel="x-position",
+    ylabel="y-position",
+    aspect=1,
+    xlim=(-4,4),
+    ylim=(-4,4),
+)
+ax2.set(
+    title="|M| in xz-plane",
+    xlabel="x-position",
+    ylabel="z-position",
+    aspect=1,
+    xlim=(-4,4),
+    ylim=(-4,4),
+)
+
+plt.tight_layout()
+plt.show()
+```
+
+The two figures show that the magnetization is zero outside of the cylinder, as well as in the overlap region where the two magnetizations cancel.
+
+Finally, we want to show that the superposition gives the same result as a computation from the CylinderSegment solution.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+from magpylib.magnet import Cylinder, CylinderSegment
+
+# Create ring with CylinderSegment
+ring1 = CylinderSegment(polarization=(0, 0, .1), dimension=(2, 3, 5, 0, 360))
+
+# Print results
+print("CylinderSegment result:", ring1.getB((.01, .02, .03)))
+print("        Cut-out result:", ring0.getB((.01, .02, .03)))
+```
+
+Note that it is faster to compute the `Cylinder` field two times than computing the `CylinderSegment` field one time. This is why Magpylib automatically falls back to the `Cylinder` solution whenever `CylinderSegment` is called with 360 deg section angles.
+
+Unfortunately, with respect to 3D-models, cut-out operations cannot be displayed graphically at this point in time, but {ref}`examples-own-3d-models` offer custom solutions.
+
+## Nice example
+
+The following example combines union and cut-out to create a complex magnet shape which is then displayed by combining a streamplot with a contourplot in matplotlib.
+
+```{code-cell} ipython3
+import numpy as np
+import matplotlib.pyplot as plt
+import magpylib as magpy
+
+fig, ax = plt.subplots(1, 1, figsize=(6, 5))
+
+# Create a magnet with superposition and cut-out
+pt1 = magpy.magnet.Cuboid(
+    polarization=(1, 0, 0), dimension=(4, 8, 2), position=(-2, 0, 0)
+)
+pt2 = magpy.magnet.CylinderSegment(
+    polarization=(1, 0, 0), dimension=(0, 4, 2,-90,90)
+)
+pt3 = magpy.magnet.Cuboid(
+    polarization=(-1/np.sqrt(2), 1/np.sqrt(2), 0), dimension=(4, 4, 2),
+).rotate_from_angax(45, 'z')
+magnet = magpy.Collection(pt1, pt2, pt3)
+
+# Compute J on mesh and plot with streamplot
+grid = np.mgrid[-6:6:100j, -6:6:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+J = magnet.getJ(grid)
+J[J<1e-12] = 0 # cut off numerically small values
+Jx, Jy, _ = np.moveaxis(J, 2, 0)
+
+ax.contourf(X, Y, np.linalg.norm(J,axis=2), cmap=plt.cm.cool)
+ax.streamplot(X, Y, Jx, Jy, color='k', density=1.5)
+
+# Plot styling
+ax.set(
+    title="Polarization J in xy-plane",
+    xlabel="x-position",
+    ylabel="y-position",
+    aspect=1,
+)
+plt.tight_layout()
+plt.show()
+```
diff --git a/docs/_pages/user_guide/examples/examples_shapes_triangle.md b/docs/_pages/user_guide/examples/examples_shapes_triangle.md
new file mode 100644
index 000000000..bb2a211b8
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_shapes_triangle.md
@@ -0,0 +1,196 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.15.2
+kernelspec:
+  display_name: Python 3 (ipykernel)
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-shapes-triangle)=
+
+# Triangular Meshes
+
+The magnetic field of a homogeneously magnetized body is equivalent to the field of a charged surface. The surface is the hull of the body, and the charge density is proportional to the projection of the magnetization vector onto the surface normal.
+
+It is very common to approximate the surface of bodies by triangular meshes, which can then be transformed into magnets using the `Triangle` and the `TriangularMesh` classes. When using these classes one should abide by the following rules:
+
+1. The surface must be closed, or all missing faces must have zero charge (magnetization vector perpendicular to surface normal).
+2. All triangles are oriented outwards (right-hand-rule)
+3. The surface must not be self-intersecting.
+4. For the B-field magnetic polarization must be added on the inside of the body.
+
+## Cuboctahedron Magnet
+
+In this example `Triangle` is used to create a magnet with cuboctahedral shape. Notice that triangle orientation is displayed by default for convenience.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create collection of triangles
+triangles_cm = [
+    ([0, 1, -1], [-1, 1, 0], [1, 1, 0]),
+    ([0, 1, 1], [1, 1, 0], [-1, 1, 0]),
+    ([0, 1, 1], [-1, 0, 1], [0, -1, 1]),
+    ([0, 1, 1], [0, -1, 1], [1, 0, 1]),
+    ([0, 1, -1], [1, 0, -1], [0, -1, -1]),
+    ([0, 1, -1], [0, -1, -1], [-1, 0, -1]),
+    ([0, -1, 1], [-1, -1, 0], [1, -1, 0]),
+    ([0, -1, -1], [1, -1, 0], [-1, -1, 0]),
+    ([-1, 1, 0], [-1, 0, -1], [-1, 0, 1]),
+    ([-1, -1, 0], [-1, 0, 1], [-1, 0, -1]),
+    ([1, 1, 0], [1, 0, 1], [1, 0, -1]),
+    ([1, -1, 0], [1, 0, -1], [1, 0, 1]),
+    ([0, 1, 1], [-1, 1, 0], [-1, 0, 1]),
+    ([0, 1, 1], [1, 0, 1], [1, 1, 0]),
+    ([0, 1, -1], [-1, 0, -1], [-1, 1, 0]),
+    ([0, 1, -1], [1, 1, 0], [1, 0, -1]),
+    ([0, -1, -1], [-1, -1, 0], [-1, 0, -1]),
+    ([0, -1, -1], [1, 0, -1], [1, -1, 0]),
+    ([0, -1, 1], [-1, 0, 1], [-1, -1, 0]),
+    ([0, -1, 1], [1, -1, 0], [1, 0, 1]),
+]
+triangles = np.array(triangles_cm) / 100  # cm -> m
+cuboc = magpy.Collection()
+for t in triangles:
+    cuboc.add(
+        magpy.misc.Triangle(
+            polarization=(0.1, 0.2, 0.3),
+            vertices=t,
+        )
+    )
+
+# Display collection of triangles
+magpy.show(
+    cuboc,
+    backend="pyvista",
+    style_magnetization_mode="arrow",
+    style_orientation_color="yellow",
+)
+```
+
+## Triangular Prism Magnet
+
+Consider a prism with triangular base that is magnetized orthogonal to the base. All surface normals of the sides of the prism are orthogonal to the magnetization vector. As a result, the sides do not contribute to the magnetic field because their charge density disappears. Only the top and bottom surfaces contribute. One must be very careful when defining those surfaces in such a way that the surface normals point outwards.
+
+Leaving out parts of the surface that do not contribute to the field is beneficial for computation speed.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Create prism magnet as triangle collection
+top = magpy.misc.Triangle(
+    polarization=(0, 0, 1),
+    vertices=((-0.01, -0.01, 0.01), (0.01, -0.01, 0.01), (0, 0.02, 0.01)),
+    style_label="top",
+)
+bott = magpy.misc.Triangle(
+    polarization=(0, 0, 1),
+    vertices=((-0.01, -0.01, -0.01), (0, 0.02, -0.01), (0.01, -0.01, -0.01)),
+    style_label="bottom",
+)
+prism = magpy.Collection(top, bott)
+
+# Display graphically
+magpy.show(*prism, backend="plotly", style_opacity=0.5, style_magnetization_show=False)
+```
+
+## TriangularMesh class
+
+While `Triangle` simply provides the field of a charged triangle and can be used to construct complex forms, it is prone to error and tedious to work with when meshes become large. For this purpose, the `TriangularMesh` class ensures proper and convenient magnet creation by automatically checking mesh integrity and by orienting the faces at initialization.
+
+```{attention}
+Automatic face reorientation of `TriangularMesh` may fail when the mesh is open.
+```
+
+In this example we revisit the cuboctahedron but generate it through the `TriangularMesh` class.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create cuboctahedron magnet (vertices and faces are transposed here for more compact display)
+vertices_cm = [
+    [0, -1, 1, 0, -1, 0, 1, 1, 0, -1, -1, 1],
+    [1, 1, 1, 1, 0, -1, 0, 0, -1, 0, -1, -1],
+    [-1, 0, 0, 1, 1, 1, 1, -1, -1, -1, 0, 0],
+]
+vertices = np.array(vertices_cm).T / 100  # cm -> m
+faces = [
+    [0, 3, 3, 3, 0, 0, 5, 8, 1, 10, 2, 11, 3, 3, 0, 0, 8, 8, 5, 5],
+    [1, 2, 4, 5, 7, 8, 10, 11, 9, 4, 6, 7, 1, 6, 9, 2, 10, 7, 4, 11],
+    [2, 1, 5, 6, 8, 9, 11, 10, 4, 9, 7, 6, 4, 2, 1, 7, 9, 11, 10, 6],
+]
+faces = np.array(faces).T
+cuboc = magpy.magnet.TriangularMesh(
+    polarization=(0.1, 0.2, 0.3), vertices=vertices, faces=faces
+)
+
+# Display TriangularMesh body
+magpy.show(
+    cuboc, backend="plotly", style_mesh_grid_show=True, style_mesh_grid_line_width=4
+)
+```
+
+The `TriangularMesh` class is extremely powerful as it enables almost arbitrary magnet shapes. It is described in detail in {ref}`docu-magpylib-api-trimesh`. There are many ways to generate such triangular meshes. An example thereof is shown in {ref}`examples-shapes-pyvista`.
+
+```{caution}
+* `getB` and `getH` compute the fields correctly only if the mesh is closed, not self-intersecting, and all faces are properly oriented outwards.
+
+* Input checks and face reorientation can be computationally expensive. The checks can individually be deactivated by setting `reorient_faces="skip"`, `check_open="skip"`, `check_disconnected="skip"`, and `check_selfintersecting="skip"` at initialization of `TriangularMesh` objects. The checks can also be performed by hand after initialization.
+
+* Meshing tools such as the [Pyvista](https://docs.pyvista.org/) library can be very convenient for building complex shapes, but often do not guarantee that the mesh is properly closed or connected - see {ref}`examples-shapes-pyvista`.
+
+* Meshing tools often create meshes with a lot of faces, especially when working with curved surfaces. Keep in mind that field computation takes of the order of a few microseconds per observer position per face, and that RAM is a limited resource.
+```
+
+## Open TriangularMesh
+
+In some cases, it may be desirable to generate a `TriangularMesh` object from an open mesh (see Prism example above). In this case one must be extremely careful because one cannot rely on the checks. Not to generate warnings or error messages, these checks can be disabled with `"skip"` or their outcome can be ignored with `"ignore"`. The `show` function can be used to view open edges and disconnected parts. In the following example we generate such an open mesh directly from `Triangle` objects.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Create top and bottom faces of a prism magnet
+top = magpy.misc.Triangle(
+    polarization=(1, 0, 0),
+    vertices=((-0.01, -0.01, 0.01), (0.01, -0.01, 0.01), (0, 0.02, 0.01)),
+)
+bottom = magpy.misc.Triangle(
+    polarization=(1, 0, 0),
+    vertices=((-0.01, -0.01, -0.01), (0, 0.02, -0.01), (0.01, -0.01, -0.01)),
+)
+
+# Create prism with open edges
+prism = magpy.magnet.TriangularMesh.from_triangles(
+    polarization=(0, 0, 1),  # overrides triangles magnetization
+    triangles=[top, bottom],
+    check_open="ignore",  # check but ignore open mesh
+    check_disconnected="ignore",  # check but ignore disconnected mesh
+    reorient_faces="ignore",  # check but ignore non-orientable mesh
+)
+prism.style.label = "Open Prism"
+prism.style.magnetization.mode = "arrow"
+
+print("mesh status open:", prism.status_open)
+print("mesh status disconnected:", prism.status_disconnected)
+print("mesh status self-intersecting:", prism.status_selfintersecting)
+print("mesh status reoriented:", prism.status_reoriented)
+
+prism.show(
+    backend="plotly",
+    style_mesh_open_show=True,
+    style_mesh_disconnected_show=True,
+)
+```
+
+```{caution}
+Keep in mind that the inside-outside check will fail, so that `getB` may yield wrong results on the inside of the prism where the polarization vector should be added.
+```
diff --git a/docs/_pages/user_guide/examples/examples_tutorial_collection.md b/docs/_pages/user_guide/examples/examples_tutorial_collection.md
new file mode 100644
index 000000000..ac1a81d3b
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_tutorial_collection.md
@@ -0,0 +1,263 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-tutorial-collection)=
+
+# Working with Collections
+
+The top-level class `Collection` allows users to group objects by reference for common manipulation. The following concepts apply to Magpylib Collections:
+
+1. A collection spans its own local frame of reference with position and orientation, to which the children are added. Thus, any operation applied to the collection is individually applied to all its children.
+2. The collection itself behaves like a single Magpylib object (can be source and/or observer).
+3. All children inside the collection can be individually addressed and manipulated, which will automatically manipulate their state inside the parent collection.
+4. Collections have their own `style` attributes, their paths are displayed in `show`, and all children are automatically assigned their parent color.
+
+## Constructing Collections
+
+Collections have the attributes `children`, `sources`, `sensors` and `collections`. These attributes are ordered lists that contain objects that are added to the collection by reference (not copied). `children` returns a list of all objects in the collection. `sources` returns a list of the sources, `sensors` a list of the sensors and `collections` a list of "sub-collections" within the collection.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+x1 = magpy.Sensor(style_label="x1")
+s1 = magpy.magnet.Cuboid(style_label="s1")
+c1 = magpy.Collection(style_label="c1")
+
+coll = magpy.Collection(x1, s1, c1, style_label="coll")
+
+print(f"children:    {coll.children}")
+print(f"sources:     {coll.sources}")
+print(f"sensors:     {coll.sensors}")
+print(f"collections: {coll.collections}")
+```
+
+New additions are always added at the end. Use the **`add`** method or the parameters.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+# Copy adjusts object label automatically
+x2 = x1.copy()
+s2 = s1.copy()
+c2 = c1.copy()
+
+# Add objects with add method
+coll.add(x2, s2)
+
+# Add objects with parameters
+coll.collections += [c2]
+
+print(f"children:    {coll.children}")
+print(f"sources:     {coll.sources}")
+print(f"sensors:     {coll.sensors}")
+print(f"collections: {coll.collections}")
+```
+
+The **`describe`** method is a very convenient way to view a Collection structure, especially when the collection is nested, i.e., when containing other collections.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+# Add more objects
+c1.add(x2.copy())
+c2.add(s2.copy())
+
+coll.describe(format="label+type")
+```
+
+The parameter `format` can be any combination of `"type"`, `"label"`, `"id"` and `"properties"`.
+
+For convenience, any two Magpylib objects can be added up with `+` to form a collection.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+x1 = magpy.Sensor(style_label="x1")
+s1 = magpy.magnet.Cuboid(style_label="s1")
+
+coll = x1 + s1
+
+coll.describe(format="label")
+```
+
+## Child-Parent Relations
+
+Objects that are part of a collection become children of that collection, and the collection itself becomes their parent. Every Magpylib object has the `parent` attribute, which is `None` by default.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+x1 = magpy.Sensor()
+c1 = magpy.Collection(x1)
+
+print(f"x1.parent:   {x1.parent}")
+print(f"c1.parent:   {c1.parent}")
+print(f"c1.children: {c1.children}")
+```
+
+Rather than adding objects to a collection, as described above, one can also set the `parent` parameter. A Magpylib object can only have a single parent, i.e., it can only be part of a single collection. As a result, changing the parent will automatically remove the object from its previous collection.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+x1 = magpy.Sensor(style_label="x1")
+c1 = magpy.Collection(style_label="c1")
+c2 = magpy.Collection(c1, style_label="c2")
+
+print("Two empty, nested collections")
+c2.describe(format="label")
+
+print("\nSet x1 parent to c1")
+x1.parent = c1
+c2.describe(format="label")
+
+print("\nChange x1 parent to c2")
+x1.parent = c2
+c2.describe(format="label")
+```
+
+## Accessing Children
+
+Collections have `__getitem__` through the attribute `children` defined which enables using collections directly as iterators,
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+x1 = magpy.Sensor()
+x2 = magpy.Sensor()
+
+coll = x1 + x2
+
+for child in coll:
+    print(child)
+```
+
+and makes it possible to directly reference to a child object by index:
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+print(coll[0])
+```
+
+Collection nesting is powerful to create a self-consistent hierarchical structure, however, it is often in the way of quick child access in nested trees. For this, the `children_all`, `sources_all`, `sensors_all` and `collections_all` read-only parameters, return all objects in the tree:
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+s1 = magpy.Sensor(style_label="s1")
+s2 = s1.copy()
+s3 = s2.copy()
+
+# This creates a nested collection
+coll = s1 + s2 + s3
+coll.describe(format="label")
+
+# _all gives access to the whole tree
+print([s.style.label for s in coll.sensors_all])
+```
+
+## Practical Example
+
+The following example demonstrates how collections enable user-friendly manipulation of groups, sub-groups, and individual objects.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Construct two coils from windings
+coil1 = magpy.Collection(style_label="coil1")
+for z in np.linspace(-0.0005, 0.0005, 5):
+    coil1.add(magpy.current.Circle(current=1, diameter=0.02, position=(0, 0, z)))
+coil1.position = (0, 0, -0.005)
+coil2 = coil1.copy(position=(0, 0, 0.005))
+
+# Helmholtz consists of two coils
+helmholtz = coil1 + coil2
+
+# Move the helmholtz
+helmholtz.position = np.linspace((0, 0, 0), (0.01, 0, 0), 15)
+helmholtz.rotate_from_angax(np.linspace(0, 180, 15), "x", start=0)
+
+# Move the coils
+coil1.move(np.linspace((0, 0, 0), (0.005, 0, 0), 15))
+coil2.move(np.linspace((0, 0, 0), (-0.005, 0, 0), 15))
+
+# Move the windings
+for coil in [coil1, coil2]:
+    for i, wind in enumerate(coil):
+        wind.move(np.linspace((0, 0, 0), (0, 0, (2 - i) * 0.001), 15))
+
+# Display as animation
+magpy.show(*helmholtz, animation=True, style_path_show=False)
+```
+
+For magnetic field computation, a collection with source children behaves like a single source object, and a collection with sensor children behaves like a flat list of its sensors when provided as `sources` and `observers` input respectively.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+
+B = magpy.getB(helmholtz, (0.01, 0, 0))
+plt.plot(
+    B * 1000,  # T -> mT
+    label=["Bx", "By", "Bz"],
+)
+
+plt.gca().set(
+    title="B-field (mT) at position x=10mm", xlabel="helmholtz path position index"
+)
+plt.gca().grid(color=".9")
+plt.gca().legend()
+plt.show()
+```
+
+## Efficient 3D Models
+
+The graphical backend libraries were not designed for complex 3D graphic output. As a result, it often becomes inconvenient and slow when attempting to display many 3D objects. One solution to this problem when dealing with large collections is to represent the latter by a single encompassing body, and to deactivate the individual 3D models of all children.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Create collection
+coll = magpy.Collection()
+for index in range(10):
+    cuboid = magpy.magnet.Cuboid(
+        polarization=(0, 0, (index % 2 - 0.5)),
+        dimension=(0.01, 0.01, 0.01),
+        position=(index * 0.01, 0, 0),
+    )
+    coll.add(cuboid)
+
+# Add an encompassing 3D-trace
+trace = magpy.graphics.model3d.make_Cuboid(
+    dimension=(0.104, 0.012, 0.012),
+    position=(0.045, 0, 0),
+    opacity=0.5,
+)
+coll.style.model3d.add_trace(trace)
+
+coll.style.label = "Collection with visible children"
+coll.show()
+
+# Hide the children default 3D representation
+coll.set_children_styles(model3d_showdefault=False)
+coll.style.label = "Collection with hidden children"
+coll.show()
+```
+
+## Compound Objects
+
+Collections can be subclassed to form dynamic groups that seamlessly integrate into Magpylib. Such classes are referred to as **compounds**. An example of how this is done is shown in {ref}`examples-misc-compound`.
diff --git a/docs/_pages/user_guide/examples/examples_tutorial_custom.md b/docs/_pages/user_guide/examples/examples_tutorial_custom.md
new file mode 100644
index 000000000..ea9983b84
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_tutorial_custom.md
@@ -0,0 +1,252 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-tutorial-custom)=
+
+# CustomSource
+
+The {ref}`guide-docs-classes-custom-source` class was implemented to offer easy integration of user field implementations into Magpylib's object-oriented interface.
+
+```{note}
+Obviously, any field implementation can be integrated. Specifically, fields where superposition holds and interactions do not disturb the sources (e.g. electric, gravitational, ...) can benefit from Magpylib's position and orientation interface.
+```
+
+## Magnetic Monopole
+
+In this example we create a class that represents the elusive magnetic monopole, which would have a magnetic field like this
+
+$$
+{\bf B} = Q_m \frac{{\bf r}}{|{\bf r}|^3}.
+$$
+
+Here the monopole lies in the origin of the local coordinates, $Q_m$ is the monopole charge and ${\bf r}$ is the observer position.
+
+We create this field as a Python function and hand it over to a CustomSource `field_func` argument. The `field_func` input must be a callable with two positional arguments `field` (can be `'B'` or `'H'`) and `observers` (must accept ndarrays of shape (n,3)), and return the respective fields in units of T and A/m in the same shape.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Create monopole field
+def mono_field(field, observers):
+    """
+    Monopole field
+
+    field: string, "B" or "H
+        return B or H-field
+
+    observers: array_like of shape (n,3)
+        Observer positions
+
+    Returns: np.ndarray, shape (n,3)
+        Magnetic monopole field
+    """
+    Qm = 1  # unit T·m²
+    obs = np.array(observers).T  # unit m
+    B = Qm * (obs / np.linalg.norm(obs, axis=0) ** 3).T  # unit T
+    if field == "B":
+        return B  # unit T
+    elif field == "H":
+        H = B / magpy.mu_0  # unit A/m
+        return H
+    else:
+        raise ValueError("Field Value must be either B or H")
+
+# Create CustomSource with monopole field
+mono = magpy.misc.CustomSource(field_func=mono_field)
+
+# Compute field
+print(mono.getB((1, 0, 0)))
+print(mono.getH((1, 0, 0)))
+```
+
+Multiple of these sources can now be combined, making use of the Magpylib position/orientation interface.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+
+# Create two monopole charges
+mono1 = magpy.misc.CustomSource(field_func=mono_field, position=(2, 2, 0))
+mono2 = magpy.misc.CustomSource(field_func=mono_field, position=(-2, -2, 0))
+
+# Compute field on observer-grid
+grid = np.mgrid[-5:5:100j, -5:5:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+B = magpy.getB([mono1, mono2], grid, sumup=True)
+Bx, By, _ = np.moveaxis(B, 2, 0)
+normB = np.linalg.norm(B, axis=2)
+
+# Plot field in x-y symmetry plane
+cp = plt.contourf(X, Y, np.log10(normB), cmap="gray_r", levels=10)
+plt.streamplot(X, Y, Bx, By, color="k", density=1)
+
+plt.title("Field of two Monopoles")
+plt.xlabel("x-position (m)")
+plt.ylabel("y-position (m)")
+
+plt.tight_layout()
+plt.show()
+```
+
+## Adding a 3D model
+
+While `CustomSource` is graphically represented by a simple marker by default, we can easily add a 3D model as described in {ref}`examples-own-3d-models`.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+# Load Sphere model
+trace_pole = magpy.graphics.model3d.make_Ellipsoid(
+    dimension=np.array([.3, .3, .3]),
+)
+
+for mono in [mono1, mono2]:
+    # Turn off default model
+    mono.style.model3d.showdefault = False
+
+    # Add sphere model
+    mono.style.model3d.add_trace(trace_pole)
+
+# Display models
+magpy.show(mono1, mono2)
+```
+
+## Subclassing CustomSource
+
+In the above example it would be nice to make the `CustomSource` dynamic, so that it would have a property `charge` that can be changed at will, rather than having to redefine the `field_func` and initialize a new object every time. In the following example we show how to sub-class `CustomSource` to achieve this. The problem is reminiscent of {ref}`examples-misc-compound`.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+class Monopole(magpy.misc.CustomSource):
+    """Magnetic Monopole class
+
+    Parameters
+    ----------
+    charge: float
+        Monopole charge in units of T·m²
+    """
+
+    def __init__(self, charge, **kwargs):
+        super().__init__(**kwargs)  # hand over style kwargs
+        self._charge = charge
+
+        # Add spherical 3d model
+        trace_pole = magpy.graphics.model3d.make_Ellipsoid(
+            dimension=np.array([.3, .3, .3]),
+        )
+        self.style.model3d.showdefault = False
+        self.style.model3d.add_trace(trace_pole)
+
+        # Add monopole field_func
+        self._update()
+
+    def _update(self):
+        """Apply monopole field function"""
+
+        def mono_field(field, observers):
+            """monopole field"""
+            Qm = self._charge  # unit T·m²
+            obs = np.array(observers).T  # unit m
+            B = Qm * (obs / np.linalg.norm(obs, axis=0) ** 3).T  # unit T
+            if field == "B":
+                return B  # unit T
+            elif field == "H":
+                H = B / magpy.mu_0  # unit A/m
+                return H
+            else:
+                raise ValueError("Field Value must be either B or H")
+
+        self.style.label = f"Monopole (charge={self._charge} T·m²)"
+        self.field_func = mono_field
+
+    @property
+    def charge(self):
+        """Return charge"""
+        return self._charge
+
+    @charge.setter
+    def charge(self, input):
+        """Set charge"""
+        self._charge = input
+        self._update()
+
+# Use new class
+mono = Monopole(charge=1)
+print(mono.getB((1, 0, 0)))
+
+# Change property charge of object
+mono.charge = -1
+print(mono.getB((1, 0, 0)))
+```
+
+The new class seamlessly integrates into the Magpylib interface as we show in the following example where we have a look at the Quadrupole field.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+
+# Create a quadrupole from four monopoles
+mono1 = Monopole(charge=1, style_color="r", position=(1, 0, 0))
+mono2 = Monopole(charge=1, style_color="r", position=(-1, 0, 0))
+mono3 = Monopole(charge=-1, style_color="b", position=(0, 0, 1))
+mono4 = Monopole(charge=-1, style_color="b", position=(0, 0, -1))
+qpole = magpy.Collection(mono1, mono2, mono3, mono4)
+
+# Matplotlib figure with 3d and 2d axis
+fig = plt.figure(figsize=(10, 5))
+ax1 = fig.add_subplot(121, projection="3d", azim=-80, elev=15)
+ax2 = fig.add_subplot(122)
+
+# Show 3D model in ax1
+magpy.show(*qpole, canvas=ax1, style_legend_show=False)
+
+# Compute B-field on xz-grid and display in ax2
+grid = np.mgrid[-2:2:100j, 0:0:1j, -2:2:100j].T[:,0]
+X, _, Z = np.moveaxis(grid, 2, 0)
+
+B = qpole.getB(grid)
+Bx, _, Bz = np.moveaxis(B, 2, 0)
+scale = np.linalg.norm(B, axis=2)**.3
+
+cp = ax2.contourf(X, Z, np.log(scale), levels=100, cmap="rainbow")
+ax2.streamplot(X, Z, Bx, Bz, density=2, color="k", linewidth=scale)
+
+# Display pole position in ax2
+ppos = np.array([mono.position for mono in qpole])
+ax2.plot(ppos[:, 0], ppos[:, 2], marker="o", ms=10, mfc="k", mec="w", ls="")
+
+# Figure styling
+ax1.set(
+    title="3D model",
+    xlabel="x-position (m)",
+    ylabel="y-position (m)",
+    zlabel="z-position (m)",
+)
+ax2.set(
+    title="Quadrupole field",
+    xlabel="x-position (m)",
+    ylabel="z-position (m)",
+    aspect=1,
+)
+fig.colorbar(cp, ax=ax2)
+
+plt.tight_layout()
+plt.show()
+```
diff --git a/docs/_pages/user_guide/examples/examples_tutorial_field_computation.md b/docs/_pages/user_guide/examples/examples_tutorial_field_computation.md
new file mode 100644
index 000000000..0e54212ee
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_tutorial_field_computation.md
@@ -0,0 +1,265 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-tutorial-field-computation)=
+
+# Computing the Field
+
+## Most basic Example
+
+The v2 slogan was *"The magnetic field is only three lines of code away"*, which is demonstrated by the following most fundamental and self-explanatory example,
+
+```{code-cell} ipython3
+import magpylib as magpy
+loop = magpy.current.Circle(current=1, diameter=1)
+B = loop.getB((0, 0, 0))
+
+print(B)
+```
+
+## Field on a Grid
+
+There are four field computation functions: `getB` will compute the B-field in T. `getH` computes the H-field in A/m. `getJ` computes the magnetic polarization in units of T. `getM` computes the magnetization in units of A/m.
+
+All these functions will return the field in the shape of the input. In the following example, BHJM-fields of a diametrically magnetized cylinder magnet are computed on a position grid in the symmetry plane and are then displayed using Matplotlib.
+
+```{code-cell} ipython3
+import matplotlib.pyplot as plt
+import numpy as np
+from numpy.linalg import norm
+import magpylib as magpy
+
+fig, [[ax1,ax2], [ax3,ax4]] = plt.subplots(2, 2, figsize=(10, 10))
+
+# Create an observer grid in the xy-symmetry plane
+grid = np.mgrid[-50:50:100j, -50:50:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+# Compute BHJM-fields of a cylinder magnet on the grid
+cyl = magpy.magnet.Cylinder(polarization=(0.5, 0.5, 0), dimension=(40, 20))
+
+B = cyl.getB(grid)
+Bx, By, _ = np.moveaxis(B, 2, 0)
+
+H = cyl.getH(grid)
+Hx, Hy, _ = np.moveaxis(H, 2, 0)
+
+J = cyl.getJ(grid)
+Jx, Jy, _ = np.moveaxis(J, 2, 0)
+
+M = cyl.getM(grid)
+Mx, My, _ = np.moveaxis(M, 2, 0)
+
+# Display field with Pyplot
+ax1.streamplot(X, Y, Bx, By, color=np.log(norm(B, axis=2)), cmap="spring_r")
+ax2.streamplot(X, Y, Hx, Hy, color=np.log(norm(H, axis=2)), cmap="winter_r")
+ax3.streamplot(X, Y, Jx, Jy, color=norm(J, axis=2), cmap="summer_r")
+ax4.streamplot(X, Y, Mx, My, color=norm(M, axis=2), cmap="autumn_r")
+
+ax1.set_title("B-Field")
+ax2.set_title("H-Field")
+ax3.set_title("J-Field")
+ax4.set_title("M-Field")
+
+for ax in [ax1,ax2,ax3,ax4]:
+    ax.set(
+        xlabel="x-position",
+        ylabel="y-position",
+        aspect=1,
+        xlim=(-50,50),
+        ylim=(-50,50),
+    )
+    # Outline magnet boundary
+    ts = np.linspace(0, 2 * np.pi, 50)
+    ax.plot(20 * np.sin(ts), 20 * np.cos(ts), "k--")
+
+plt.tight_layout()
+plt.show()
+```
+
+(examples-tutorial-field-computation-sensors)=
+
+## Using Sensors
+
+The `Sensor` class enables relative positioning of observer grids in the global coordinate system. The observer grid is stored in the `pixel` parameter of the sensor object which is `(0,0,0)` by default (sensor position = observer position).
+
+The following example shows a moving and rotating sensor with two pixels. At the same time, the source objects are moving to demonstrate the versatility of the field computation.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Reset defaults set in previous example
+magpy.defaults.reset()
+
+
+# Define sensor with path
+sensor = magpy.Sensor(pixel=[(0, 0, -0.0005), (0, 0, 0.0005)], style_size=1.5)
+sensor.position = np.linspace((0, 0, -0.003), (0, 0, 0.003), 37)
+
+angles = np.linspace(0, 360, 37)
+sensor.rotate_from_angax(angles, "z", start=0)
+
+# Define source with path
+cyl1 = magpy.magnet.Cylinder(
+    polarization=(0.1, 0, 0), dimension=(0.001, 0.002), position=(0.003, 0, 0)
+)
+cyl2 = cyl1.copy(position=(-0.003, 0, 0))
+coll = magpy.Collection(cyl1, cyl2)
+coll.rotate_from_angax(-angles, "z", start=0)
+
+# Display system and field at sensor
+with magpy.show_context(sensor, coll, animation=True, backend="plotly"):
+    magpy.show(col=1)
+    magpy.show(output="Bx", col=2, pixel_agg=None)
+```
+
+## Multiple Inputs
+
+When `getBHJM` receive multiple inputs for sources and observers they will compute all possible combinations. It is still beneficial to call the field computation only a single time, because similar sources will be grouped, and the computation will be vectorized automatically.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Three sources
+cube1 = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(0.1, 0.1, 0.1))
+cube2 = cube1.copy()
+cube3 = cube1.copy()
+
+# Two sensors with 4x5 pixel each
+pixel = [[[(i / 1000, j / 1000, 0)] for i in range(4)] for j in range(5)]
+sens1 = magpy.Sensor(pixel=pixel)
+sens2 = sens1.copy()
+
+# Compute field
+B = magpy.getB([cube1, cube2, cube3], [sens1, sens2])
+
+# The result includes all combinations
+B.shape
+```
+
+Select the second cube (first index), the first sensor (second index), pixel 3-4 (index three and four) and the Bz-component of the field (index five)
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+B[1, 0, 2, 3, 2]
+```
+
+A path will add another index. Every higher pixel dimension will add another index as well.
+
+## Field as Pandas Dataframe
+
+Instead of a NumPy `ndarray`, the field computation can also return a [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe) using the `output='dataframe'` kwarg.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+cube = magpy.magnet.Cuboid(
+    polarization=(0, 0, 1), dimension=(0.01, 0.01, 0.01), style_label="cube"
+)
+loop = magpy.current.Circle(
+    current=200,
+    diameter=0.02,
+    style_label="loop",
+)
+sens1 = magpy.Sensor(
+    pixel=[(0, 0, 0), (0.005, 0, 0)],
+    position=np.linspace((-0.04, 0, 0.02), (0.04, 0, 0.02), 30),
+    style_label="sens1",
+)
+sens2 = sens1.copy(style_label="sens2").move((0, 0, 0.01))
+
+B = magpy.getB(
+    [cube, loop],
+    [sens1, sens2],
+    output="dataframe",
+)
+
+B
+```
+
+Plotting libraries such as [plotly](https://plotly.com/python/plotly-express/) or [seaborn](https://seaborn.pydata.org/introduction.html) can take advantage of this feature, as they can deal with `dataframes` directly.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import plotly.express as px
+
+fig = px.line(
+    B,
+    x="path",
+    y="Bx",
+    color="pixel",
+    line_group="source",
+    facet_col="source",
+    symbol="sensor",
+)
+fig.show()
+```
+
+(examples-tutorial-field-computation-functional-interface)=
+
+## Functional Interface
+
+All above computations demonstrate the convenient object-oriented interface of Magpylib. However, there are instances when it is better to work with the functional interface instead.
+
+1. Reduce overhead of Python objects
+2. Complex computation instances
+
+In the following example we show how complex instances are computed using the functional interface.
+
+```{important}
+The functional interface will only outperform the object oriented interface if you use NumPy operations for input array creation, such as `tile`, `repeat`, `reshape`, ... !
+```
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Two different magnet dimensions
+dim1 = (0.02, 0.04, 0.04)
+dim2 = (0.04, 0.02, 0.02)
+DIM = np.vstack(
+    (
+        np.tile(dim1, (6, 1)),
+        np.tile(dim2, (6, 1)),
+    )
+)
+
+# Sweep through different polarizations for each magnet type
+pol = np.linspace((0, 0, 0.5), (0, 0, 1), 6)
+POL = np.tile(pol, (2, 1))
+
+# Airgap must stay the same
+pos1 = (0, 0, 0.03)
+pos2 = (0, 0, 0.02)
+POS = np.vstack(
+    (
+        np.tile(pos1, (6, 1)),
+        np.tile(pos2, (6, 1)),
+    )
+)
+
+# Compute all instances with the functional interface
+B = magpy.getB(
+    sources="Cuboid",
+    observers=POS,
+    polarization=POL,
+    dimension=DIM,
+)
+
+B.round(decimals=2)
+```
diff --git a/docs/_pages/user_guide/examples/examples_tutorial_modeling_magnets.md b/docs/_pages/user_guide/examples/examples_tutorial_modeling_magnets.md
new file mode 100644
index 000000000..a14fc3273
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_tutorial_modeling_magnets.md
@@ -0,0 +1,97 @@
+(examples-tutorial-modeling-magnets)=
+
+# Modeling a real magnet
+
+Whenever you wish to compare Magpylib simulations with experimental data obtained using a real permanent magnet, you might wonder how to properly set up a Magpylib magnet object to reflect the physical permanent magnet in question. The goal of this tutorial is to explain how to extract this information from respective datasheets, to provide better understanding of permanent magnets, and show how to align Magpylib simulations with experimental measurements.
+
+This tutorial was supported by [BOMATEC](https://www.bomatec.com/de) by providing excellent data sheets and by supplying magnets for the experimental demonstration below.
+
+## Short summary
+
+In a magnet data sheet, you should find B-H curves and J-H curves. These curves coincide at H=0, which gives the intrinsic material remanence $B_r$. As a result of material response and self-interaction, the magnet "demagnetizes" itself so that the mean magnetic polarization of a real magnet is always below the remanence. How much below depends strongly on the shape of the magnet and is expressed in the data sheet through the permeance coefficient lines (grey lines). The numbers at the end indicate the typical magnet length to diameter ratio (L/D).
+
+To obtain the correct magnetic polarization of a magnet from the data sheet, one must find the crossing between B-H curve and respective permeance coefficient line. This gives the "working point" which corresponds to the mean demagnetizing H-field inside the magnet. The correct polarization to use in the Magpylib simulation is the J-value at the working point which can be read off from the J-H curve.
+
+![data sheet snippet](../../../_static/images/examples_tutorial_magnet_datasheet2.png)
+
+The following sections provide further explanation on the matter.
+
+## Hysteresis loop
+
+If you've worked with magnetism, chances are very high that you have seen a magnetic hysteresis loop. Hysteresis loops describe the connection between the **mean values** of an externally applied H-field and the resulting B-field, polarization J or magnetization M **within a defined volume**. This connection depends strongly on the size and shape of this volume and what is inside and what is outside.
+
+The B-H curve is called the "normal loop", while J-H and M-H curves are called "intrinsic loops". Hereon we only make use of the J-H loops, but the discussion is similar for M-H. Normal and intrinsic loops are connected via $B = \mu_0 H + J$. In free space the B-H connection is just a straight line defined via $B = \mu_0 H$. When the whole space is filled with magnetic material you will see something like this within an arbitrary volume:
+
+::::{grid} 2
+:::{grid-item}
+:columns: 3
+:::
+:::{grid-item}
+:columns: 6
+![hysteresis loops](../../../_static/images/examples_tutorial_magnet_hysteresis.png)
+:::
+::::
+
+**1st quadrant**: Initially we have $J=0$ and $H=0$. The magnetic material is not magnetized, and no external H-field is applied. When increasing the H-field, the material polarization will follow the "virgin curve" and will increase until it reaches its maximum possible value, the saturation polarization $J_S$. Higher values of $H$ will not affect $J$, while $B$ will keep increasing linearly. Now we are on the "major loop" - we will never return to the virgin curve. After reaching a large H-value we slowly turn the H-field off. As it drops to zero the material will retain its strong polarization at saturation level while the resulting $B$ decreases. At $H = 0$ the B-field then approaches the "remanence field" $B_r$, and its only contribution is $J_S$.
+
+**2nd quadrant**: Now the H-field becomes negative. Its amplitude increases but it is oriented opposite to the initial direction. Therefore, it is also opposite to the magnetic polarization. In the 2nd quadrant we are now trying to actively demagnetize the material. This part of the hysteresis loop is often referred to as the "demagnetization curve". With increasing negative H, the B-field continues to become smaller until it reaches zero at the "coercive field" $H_c$. At this point the net B-field inside the volume is zero, however, the material is still magnetized! In the example loop above, the polarization at $H_c$ is still at the $J_S$ level. By increasing the H-field further, a point will be reached where the material will start to demagnetize. This can be seen by the non-linear drop of $J$. The point where $J$ reaches zero is called the "intrinsic coercive field" $H_{ci}$. At this point the net polarization in the observed volume is zero. The material is demagnetized. The intrinsic coercive field is a measure of how well a magnetized material can resist a demagnetizing field. Having large values of $H_{ci}$ is a property of "hard magnets", as they can keep their magnetization $J$ even for strong external fields in the opposite direction.
+
+**3rd and 4th quadrants**: Moving to the third quadrant the behavior is now mirrored. As $H$ increases past $H_{ci}$, polarization quickly aligns with the external field and the material becomes saturated $J=-J_S$. By turning the field around again, we move through the fourth quadrant to complete the hysteresis loop.
+
+Hysteresis in magnetism as presented here is a macroscopic model that is the result of a complex interplay between dipole and exchange interaction, material texture and resulting domain formation at a microscopic level. Details can be found, for example, in Aharoni's classical textbook "Introduction to the Theory of Ferromagnetism".
+
+## The demagnetizing field
+
+If in an application the applied external H-field is zero, it seems intuitive to use the remanence $B_r$ for the magnetic polarization in the Magpylib simulation. It is a value that you will find in the data sheet.
+
+![data sheet snippet](../../../_static/images/examples_tutorial_magnet_table.png)
+
+However, if considering $J=B_r$, you will quickly see that the experimental results are up to ~30 % below of what you would expect. The reason for this is the self-induced demagnetizing field of the magnet which is generated by the magnetic polarization itself. Just like $J$ generates an H-field on the outside of the magnet, it also generates an H-field inside the magnet, along the opposite direction of the polarization. The H-field outside the magnet is known as the stray field, and the H-field inside the magnet is called the demagnetizing field (as it opposes the magnetic polarization). This is demonstrated by the following figure:
+
+![demagnetization field simulation](../../../_static/images/examples_tutorial_magnet_fieldcomparison.png)
+
+Making use of the [streamplot example](examples-vis-mpl-streamplot), on the left side we show the cross-section of a Cuboid magnet and its homogeneous polarization. And on the right, we see the H-field generated by it. Inside the magnet the generated H-field opposes the polarization. As a result, the polarization of a magnet will not be $B_r$, but instead will be some value of $J$ in the 2nd quadrant of the J-H loop that corresponds to the mean H-field inside the magnet. This H-value is often referred to as the "working point".
+
+## Finding the correct polarization
+
+As explained above, the hysteresis loop depends strongly on the chosen observation volume geometry, the material inside, and what is outside of the volume. Magnet manufacturers provide such loops (usually only the 2nd quadrant) for their magnets, meaning that the observation volume is the whole magnet with air outside.
+
+To obtain the correct mean polarization of a magnet we simply must compute the mean demagnetizing field (= working point) and read the resulting $J$ off the provided J-H loop. Computing the mean demagnetizing field, however, is not a simple task. In addition to the material response (permeability), it depends strongly on the magnet geometry. Fortunately, the working points can be read off from well written data sheets.
+
+![data sheet snippet](../../../_static/images/examples_tutorial_magnet_datasheet.png)
+
+This datasheet snippet shows the second quadrant of the B-H and J-H loops, even for two different temperatures. The working point is given by the intersection between the "permeance coefficient" lines (gray) and the B-H curve. The number at the end of these lines indicate the length to diameter ratio (L/D) of the magnet, which is the critical geometric factor. Different lines are for different L/D values, which allows one to select the correct working point for different magnets made from this specific material. Once the working point is found, the correct magnetic polarization, here denoted by $J_W$ (the magnetic polarization at the working point), can be read off. The following figure exemplifies the changes in $J_W$ for different L/D values, considering a cylinder magnet:
+
+![finding the working point](../../../_static/images/examples_tutorial_magnet_LDratio.png)
+
+Permanent magnets with different geometries, such as a parallelepiped shape, will have different behavior in terms of L/D values. Make sure you are reading the data from the correct part number.
+## Warning
+
+Keep in mind that there are still many reasons why your simulation might not fit well to your experiment. Here are some of the most common problems:
+
+1. Small position errors of less than 100 um can have a large impact on the measurement result. The reference should be the sensitive element inside a sensor package. These elements can be displaced by 10-100 um and even rotated by a few degrees. The sensor might also not be well calibrated.
+2. There are external stray fields, like the earth magnetic field, that influence the measurements. Those might also come from other nearby electronic equipment or magnetic parts.
+3. Off-the-shelf magnets often do not hold what is promised in the datasheet
+    - Magnetic polarization amplitude can be off by 5-10 %.
+    - The direction of polarization often varies by up to a few degrees.
+    - Worst-case: the polarization can be inhomogeneous which is often a problem in injection-mold magnets.
+
+## Example
+
+::::{grid} 2
+:::{grid-item}
+:columns: 3
+:::
+:::{grid-item}
+:columns: 6
+![](../../../_static/images/examples_icon_WIP.png)
+:::
+::::
+
+coming soon:
+1. Magpylib simulation code
+2. Experimental data
+3. Comparison and discussion
+
+**Exterior reference**
+G. Martinek, S. Ruoho and U. Wyss. (2021). *Magnetic Properties of Permanents Magnets & Measuring Techniques* [White paper]. Arnold Magnetic Technologies. https://www.arnoldmagnetics.com/blog/measuring-permanent-magnets-white-paper/
diff --git a/docs/_pages/user_guide/examples/examples_tutorial_paths.md b/docs/_pages/user_guide/examples/examples_tutorial_paths.md
new file mode 100644
index 000000000..8b836721f
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_tutorial_paths.md
@@ -0,0 +1,195 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-tutorial-paths)=
+
+# Working with Paths
+
+The position and orientation attributes are key elements of Magpylib. The documentation section {ref}`docs-position` describes how they work in detail. While these definitions can seem abstract, the interface was constructed as intuitively as possible which is demonstrated in this tutorial.
+
+```{important}
+Always make use of paths when computing with multiple Magpylib object position and orientation instances. This enables vectorized computation. Avoid Python loops at all costs!
+```
+
+In this tutorial we show some good practice examples.
+
+## Assigning Absolute Paths
+
+Absolute object paths are assigned at initialization or through the object properties.
+
+```{code-cell} ipython3
+import numpy as np
+from scipy.spatial.transform import Rotation as R
+import magpylib as magpy
+
+# Create paths
+ts = np.linspace(0, 10, 31)
+pos = np.array([(0.1 * t, 0, 0.1 * np.sin(t)) for t in ts])
+ori = R.from_rotvec(np.array([(0, -0.1 * np.cos(t) * 0.785, 0) for t in ts]))
+
+# Set path at initialization
+sensor = magpy.Sensor(position=pos, orientation=ori)
+
+# Set path through properties
+cube = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(0.01, 0.01, 0.01))
+cube.position = pos + np.array((0, 0, 0.3))
+cube.orientation = ori
+
+# Display as animation
+magpy.show(sensor, cube, animation=True, backend="plotly")
+```
+
+## Relative Paths
+
+`move` and `rotate` input is interpreted relative to the existing path. When the input is scalar the whole existing path is moved.
+
+```{code-cell} ipython3
+import numpy as np
+from scipy.spatial.transform import Rotation as R
+import magpylib as magpy
+
+# Create paths
+ts = np.linspace(0, 10, 21)
+pos = np.array([(0.1 * t, 0, 0.1 * np.sin(t)) for t in ts])
+ori = R.from_rotvec(np.array([(0, -0.1 * np.cos(t) * 0.785, 0) for t in ts]))
+
+# Set path at initialization
+sens1 = magpy.Sensor(position=pos, orientation=ori, style_label="sens1")
+
+# Apply move operation to whole path with scalar input
+sens2 = sens1.copy(style_label="sens2")
+sens2.move((0, 0, 0.05))
+
+# Apply rotate operation to whole path with scalar input
+sens3 = sens1.copy(style_label="sens3")
+sens3.rotate_from_angax(angle=90, axis="y", anchor=0)
+
+# Display paths
+magpy.show(sens1, sens2, sens3)
+```
+
+When the input is a vector, the path is by default appended.
+
+```{code-cell} ipython3
+import numpy as np
+from magpylib.magnet import Sphere
+
+# Create paths
+x_path = np.linspace((0, 0, 0), (0.1, 0, 0), 10)[1:]
+z_path = np.linspace((0, 0, 0), (0, 0, 0.1), 10)[1:]
+
+# Create sphere object
+sphere = Sphere(polarization=(0, 0, 1), diameter=0.03)
+
+# Apply paths subsequently
+for _ in range(3):
+    sphere.move(x_path).move(z_path)
+
+# Display paths
+sphere.show()
+```
+
+## Merging paths
+
+Complex paths can be created by merging multiple path operations. This is done with vector input for the `move` and `rotate` methods and choosing values for `start` that will make the paths overlap. In the following example we combine a linear path with a rotation about self (`anchor=None`) until path index 30. Thereon, a second rotation about the origin is applied, creating a spiral.
+
+```{code-cell} ipython3
+import numpy as np
+from magpylib.magnet import Cuboid
+
+# Create cube and set linear path
+cube = Cuboid(polarization=(0, 0, 0.1), dimension=(0.02, 0.02, 0.02))
+cube.position = np.linspace((0, 0, 0), (0.1, 0, 0), 60)
+
+# Apply rotation about self - starting at index 0
+cube.rotate_from_rotvec(np.linspace((0, 0, 0), (0, 0, 360), 30), start=0)
+
+# Apply rotation about origin - starting at index 30
+cube.rotate_from_rotvec(np.linspace((0, 0, 0), (0, 0, 360), 30), anchor=0, start=30)
+
+# Display paths as animation
+cube.show(backend="plotly", animation=True)
+```
+
+## Reset path
+
+The `reset_path()` method allows users to reset an object path to `position=(0,0,0)` and `orientation=None`.
+
+```{code-cell} ipython3
+import magpylib as magpy
+
+# Create sensor object with complex path
+sensor = magpy.Sensor().rotate_from_angax(
+    [1, 2, 3, 4], (1, 2, 3), anchor=(0, 0.03, 0.05)
+)
+
+# Reset path
+sensor.reset_path()
+
+print(sensor.position)
+print(sensor.orientation.as_quat())
+```
+
+(examples-tutorial-paths-edge-padding-end-slicing)=
+## Edge-padding and end-slicing
+
+Magpylib will always make sure that object paths are in the right format, i.e., `position` and `orientation` attributes are of the same length. In addition, when objects with different path lengths are combined, e.g., when computing the field, the shorter paths are treated as static beyond their end to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths.
+
+The idea behind **edge-padding** is that, whenever path entries beyond the existing path length are needed, the edge-entries of the existing path are returned. This means that the object is static beyond its existing path.
+
+In the following example the orientation attribute is padded by its edge value `(0,0,.2)` as the position attribute length is increased.
+
+```{code-cell} ipython3
+from scipy.spatial.transform import Rotation as R
+import magpylib as magpy
+
+sensor = magpy.Sensor(
+    position=[(0, 0, 0), (0.01, 0.01, 0.01)],
+    orientation=R.from_rotvec([(0, 0, 0.1), (0, 0, 0.2)]),
+)
+sensor.position = [(i / 100, i / 100, i / 100) for i in range(4)]
+print(sensor.position)
+print(sensor.orientation.as_rotvec())
+```
+
+When the field is computed of `loop1` with path length 4 and `loop2` with path length 2, `loop2` will remain in position (= edge padding) while the other object is still in motion.
+
+```{code-cell} ipython3
+from magpylib.current import Circle
+
+loop1 = Circle(current=1, diameter=1, position=[(0, 0, i) for i in range(4)])
+loop2 = Circle(current=1, diameter=1, position=[(0, 0, i) for i in range(2)])
+
+B = magpy.getB([loop1, loop2], (0, 0, 0))
+print(B)
+```
+
+The idea behind **end-slicing** is that, whenever a path is automatically reduced in length, Magpylib will slice to keep the ending of the path. While this occurs rarely, the following example shows how the `orientation` attribute is automatically end-sliced, keeping the values `[(0,0,.3), (0,0,.4)]`, when the `position` attribute is reduced in length:
+
+```{code-cell} ipython3
+from scipy.spatial.transform import Rotation as R
+from magpylib import Sensor
+
+sensor = Sensor(
+    position=[(0, 0, 0), (0.01, 0.01, 0.01), (0.02, 0.02, 2), (0.03, 0.03, 0.03)],
+    orientation=R.from_rotvec([(0, 0, 0.1), (0, 0, 0.2), (0, 0, 0.3), (0, 0, 0.4)]),
+)
+sensor.position = [(0.01, 0.02, 0.03), (0.02, 0.03, 0.04)]
+print(sensor.position)
+print(sensor.orientation.as_rotvec())
+```
+
+```{code-cell} ipython3
+
+```
diff --git a/docs/_pages/user_guide/examples/examples_vis_animations.md b/docs/_pages/user_guide/examples/examples_vis_animations.md
new file mode 100644
index 000000000..a7490881b
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_vis_animations.md
@@ -0,0 +1,298 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.1
+kernelspec:
+  display_name: Python 3 (ipykernel)
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-vis-animations)=
+
+# Animations
+
+Magpylib can display the motion of objects along paths in the form of animations.
+
+```{hint}
+1. Animations work best with the [plotly backend](guide-graphic-backends).
+
+2. If your browser window opens, but your animation does not load, reload the page.
+
+3. Avoid rendering too many frames.
+```
+
+Detailed information about how to tune animations can be found in the [graphics documentation](guide-graphic-animations).
+
+## Simple Animations
+
+Animations are created with `show` by setting `animation=True`. It is also possible to hand over the animation time with this parameter.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import numpy as np
+
+# Define magnet with path
+magnet = magpy.magnet.Cylinder(
+    polarization=(1, 0, 0),
+    dimension=(2, 1),
+    position=(4, 0, 0),
+    style_label="magnet",
+)
+magnet.rotate_from_angax(angle=np.linspace(0, 300, 40), start=0, axis="z", anchor=0)
+
+# Define sensor with path
+sensor = magpy.Sensor(
+    pixel=[(-0.2, 0, 0), (0.2, 0, 0)],
+    position=np.linspace((0, 0, -3), (0, 0, 3), 40),
+    style_label="sensor",
+)
+
+# Display as animation - prefers plotly backend
+magpy.show(sensor, magnet, animation=True, backend="plotly")
+```
+
+(examples-vis-animated-subplots)=
+
+## Animated Subplots
+
+[Subplots](examples-vis-subplots) are a powerful tool to see the field along a path while viewing the 3D models at the same time. This is specifically illustrative as an animation where the field at the respective path position is indicated by a marker.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+magpy.show(
+    dict(objects=[magnet, sensor], output=["Bx", "By", "Bz"], col=1),
+    dict(objects=[magnet, sensor], output="model3d", col=2),
+    backend="plotly",
+    animation=True,
+)
+```
+
+It is also possible to use the [show_context](guide-graphics-show_context) context manager.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+with magpy.show_context([magnet, sensor], backend="plotly", animation=True) as sc:
+    sc.show(output="Bx", col=1, row=1)
+    sc.show(output="By", col=1, row=2)
+    sc.show(output="Bz", col=2, row=1)
+    sc.show(output="model3d", col=2, row=2)
+```
+
+(examples-vis-exporting-animations)=
+
+## Exporting Animations
+
+Animations are wonderful but can be quite difficult to export when they are needed, for example, in a presentation. Here we show how to creat and export animations using the *.gif format.
+
+### Built-in export
+
+The easiest way to export an animation is via the Magpylib built-in command `animation_output` in the `show` function. It works only with the Pyvista backend. The following code will create a file "test4.gif".
+
+```python
+import magpylib as magpy
+import numpy as np
+
+# Create magnets with Paths
+path = [(np.sin(t) + 1.5, 0, 0) for t in np.linspace(0, 2 * np.pi, 30)]
+cube1 = magpy.magnet.Cuboid(dimension=(1, 1, 1), polarization=(1, 0, 0), position=path)
+cube2 = cube1.copy(position=-np.array(path), polarization=(-1, 0, 0))
+
+# Store gif with animation_output using Pyvista
+magpy.show(
+    cube1,
+    cube2,
+    style_legend_show=False,
+    animation=3,
+    animation_output="my.gif",
+    backend="pyvista",
+    style_path_show=False,
+)
+```
+
+<img src="../../../_static/videos/example_gif1.gif" width=50% align="center">
+
+### Custom export Pyvista
+
+For customizing videos it is best to work directly in the respective graphic backends. Here we show how to transfer the Magpylib graphic objects to a Pyvista plotter, customize the plotting scene, export screen shots, and combine them in a *.gif. The following example also shows how to achieve transparency.
+
+```python
+import magpylib as magpy
+import pyvista as pv
+from PIL import Image
+
+
+def create_gif(images, frame_time, output_file):
+    """Create a GIF from images"""
+    frames = [Image.fromarray(img) for img in images]
+    if frames:
+        frames[0].save(
+            output_file,
+            format="GIF",
+            append_images=frames[1:],
+            save_all=True,
+            duration=frame_time,
+            loop=0,  # Infinite loop
+            disposal=2,  # Remove previous frames for transparency
+        )
+
+
+def init_plotter():
+    """Init Pyvista plotter with custom scene layout"""
+    pl = pv.Plotter(notebook=False, off_screen=True, window_size=[300, 300])
+    pl.camera_position = [
+        (5, 5, 5),  # Position of the camera
+        (0, 0, 0),  # Focal point (what the camera is looking at)
+        (0, 0, 1),  # View up direction
+    ]
+    pl.camera.zoom(0.5)
+    pl.set_background("k")  # For better transparency
+    return pl
+
+
+def create_frames(frames):
+    """Create frames with Pyvista."""
+
+    # Create Magpylib objects
+    mag1 = magpy.magnet.CylinderSegment(
+        dimension=(3, 4, 1, 0, 45), polarization=(0, 0, 1)
+    )
+    mag2 = magpy.magnet.CylinderSegment(
+        dimension=(2, 3, 1, 0, 45), polarization=(0, 0, -1)
+    )
+
+    images = []
+    pl = init_plotter()
+    for i in range(frames):
+
+        # Modify object positions
+        mag1.rotate_from_angax(360 / frames, axis="z")
+        mag2.rotate_from_angax(-360 / frames, axis="z")
+
+        # Transfer Magpylib objects to Pyvista plotter
+        pl.clear()
+        magpy.show(mag1, mag2, canvas=pl, style_legend_show=False)
+
+        # Edit figure in Pyvista
+        pl.add_mesh(pv.Line(mag1.barycenter, mag2.barycenter), color="cyan")
+
+        # Screenshot
+        print(f"Writing frame {i+1:3d}/{frames}")
+        ss = pl.screenshot(transparent_background=True, return_img=True)
+        images.append(ss)
+
+    pl.close()
+    return images
+
+
+def main():
+    frames = 100
+    frame_time = 40
+    output_file = "my.gif"
+
+    images = create_frames(frames)
+    create_gif(images, frame_time, output_file)
+
+
+if __name__ == "__main__":
+    main()
+```
+
+<img src="../../../_static/videos/example_gif2.gif" width=50% align="center">
+
+Notice that when providing a canvas, no update to its layout is performed by Magpylib, unless explicitly specified by setting `canvas_update=True` in `show()`. By default `canvas_update="auto"` only updates the canvas if is not provided by the user. Details can be found in the [graphics documentation](guide-graphics-canvas).
+
+### Custom export Plotly
+
+The following examples shows how to work in the Plotly backend.
+
+```python
+import magpylib as magpy
+from PIL import Image
+import io
+
+
+def create_gif(images, frame_time, output_file):
+    """Create GIF from frames in the temporary directory."""
+    frames = [Image.open(io.BytesIO(data)) for data in images]
+    if frames:
+        frames[0].save(
+            output_file,
+            format="GIF",
+            append_images=frames[1:],
+            save_all=True,
+            duration=frame_time,
+            loop=0,  # Infinite loop
+        )
+
+
+def create_frames(frames):
+    """Create frames with Pyvista."""
+
+    # Create Magpylib objects
+    mag1 = magpy.magnet.CylinderSegment(
+        dimension=(3, 4, 1, 0, 45), polarization=(0, 0, 1)
+    )
+    mag2 = magpy.magnet.CylinderSegment(
+        dimension=(2, 3, 1, 0, 45), polarization=(0, 0, -1)
+    )
+
+    images = []
+    for i in range(frames):
+        # Set object position
+        mag1.rotate_from_angax(360 / frames, axis="z")
+        mag2.rotate_from_angax(-360 / frames, axis="z")
+
+        fig = magpy.show(
+            mag1, mag2, return_fig=True, backend="plotly", style_legend_show=False
+        )
+
+        # Edit figure in Plotly
+        fig.add_scatter3d(
+            x=(0, 0, 4, 4, 0),
+            y=(0, 0, 0, 0, 0),
+            z=(-2, 2, 2, -2, -2),
+            mode="lines",
+            line_color="black",
+        )
+
+        # Customize layout
+        fig.update_layout(
+            scene=dict(
+                camera_eye={"x": 1.5, "y": 1.5, "z": 1.5},
+                camera_up={"x": 0, "y": 0, "z": 1},
+                xaxis_range=(-5, 5),
+                yaxis_range=(-5, 5),
+                zaxis_range=(-5, 5),
+            ),
+            showlegend=False,
+            margin=dict(l=0, r=0, t=0, b=0),
+        )
+
+        # Screenshot (requires kaleido package)
+        print(f"Writing frame {i+1:3d}/{frames}")
+        img = fig.to_image(format="png", width=500, height=500)
+        images.append(img)
+    return images
+
+
+def main():
+    frames = 50
+    frame_time = 50
+    output_file = "my.gif"
+
+    images = create_frames(frames)
+    create_gif(images, frame_time, output_file)
+
+
+if __name__ == "__main__":
+    main()
+```
+
+<img src="../../../_static/videos/example_gif3.gif" width=50% align="center">
diff --git a/docs/_pages/user_guide/examples/examples_vis_magnet_colors.md b/docs/_pages/user_guide/examples/examples_vis_magnet_colors.md
new file mode 100644
index 000000000..bf0218a58
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_vis_magnet_colors.md
@@ -0,0 +1,71 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.14.5
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-vis-magnet-colors)=
+# Magnet colors
+
+The polarization direction of a permanent magnet is often graphically displayed with the help of colors. However, there is no unified color scheme that everyone agrees on. The following image shows some color-examples from the web.
+
+![](../../../_static/images/examples_vis_magnet_colors.png)
+
+Magpylib uses the DIN Specification 91411 (soon 91479) standard as default setting. The tri-color scheme has the advantage that for multi-pole elements it becomes clear which north is "connected" to which south.
+
+```{hint}
+The color schemes often seem to represent homogeneous polarizations, referred to as "ideal typical" magnets in DIN Specification 91479. However, they often just represent general "pole patterns", i.e. rough sketches where the field goes in and where it comes out, that are not the result of homogeneous polarizations. On this topic review also the examples example {ref}`examples-misc-inhom`, and the tutorial {ref}`examples-tutorial-modeling-magnets`.
+```
+
+With Magpylib users can easily tune the magnet color schemes. The `style` options are `tricolor` with north, middle and south colors, and `bicolor` with north and south colors.
+
+```{code-cell} ipython
+import magpylib as magpy
+
+# Create a magnetization style dictionary
+mstyle = dict(
+    mode="color+arrow",
+    color=dict(north="magenta", middle="white", south="turquoise"),
+    arrow=dict(width=2, color="k")
+)
+
+# Create magnet and apply style
+sphere = magpy.magnet.Sphere(
+    polarization=(1, 1, 1),
+    diameter=1,
+    position=(-1, 1, 0),
+    style_magnetization=mstyle,
+)
+
+# Create a second magnet with different style
+cube = magpy.magnet.Cuboid(
+    polarization=(1, 0, 0),
+    dimension=(1, .2, .2),
+    position=(1, 1, 0),
+    style_magnetization_color_mode="bicolor",
+    style_magnetization_color_north="r",
+    style_magnetization_color_south="g",
+    style_magnetization_color_transition=0,
+)
+
+# Create a third magnet with different style
+cyl = magpy.magnet.CylinderSegment(
+    polarization=(1, 0, 0),
+    dimension=(1.7, 2, .3, -145, -35),
+)
+cyl.style.magnetization.color.north = "cornflowerblue"
+cyl.style.magnetization.color.south = "orange"
+
+# Show all three
+magpy.show(sphere, cube, cyl, backend='plotly', style_legend_show=False)
+```
+
+More information about styles and how to apply them is given in the user-guide [style section](guide-graphic-styles).
diff --git a/docs/_pages/user_guide/examples/examples_vis_mpl_streamplot.md b/docs/_pages/user_guide/examples/examples_vis_mpl_streamplot.md
new file mode 100644
index 000000000..223a214a6
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_vis_mpl_streamplot.md
@@ -0,0 +1,131 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-vis-mpl-streamplot)=
+
+# Matplotlib Streamplot
+
+## Example 1: Cuboid Magnet
+
+In this example we show the B-field of a cuboid magnet using Matplotlib streamlines. Streamlines are not magnetic field lines in the sense that the field amplitude cannot be derived from their density. However, Matplotlib streamlines can show the field amplitude via color and line thickness. One must be careful that streamlines can only display two components of the field. In the following example the third field component is always zero - but this is generally not the case.
+
+In the example we make use of the [scaling property](guide-docs-io-scale-invariance). We assume that all length inputs are in units of mm, and that the polarization input is in units of millitesla. The resulting `getB` output will also be in millitesla. One must be careful with scaling - the conversion to H would ofc give units of mA/m.
+
+```{code-cell} ipython3
+import matplotlib.pyplot as plt
+import numpy as np
+
+import magpylib as magpy
+
+# Create a Matplotlib figure
+fig, ax = plt.subplots()
+
+# Create an observer grid in the xz-symmetry plane
+ts = np.linspace(-5, 5, 40)
+grid = np.array([[(x, 0, z) for x in ts] for z in ts])
+X, _, Z = np.moveaxis(grid, 2, 0)
+
+# Compute the B-field of a cube magnet on the grid
+cube = magpy.magnet.Cuboid(polarization=(500,0,500), dimension=(2,2,2))
+B = cube.getB(grid)
+Bx, _, Bz = np.moveaxis(B, 2, 0)
+log10_norm_B = np.log10(np.linalg.norm(B, axis=2))
+
+# Display the B-field with streamplot using log10-scaled
+# color function and linewidth
+splt = ax.streamplot(X, Z, Bx, Bz,
+    density=1.5,
+    color=log10_norm_B,
+    linewidth=log10_norm_B,
+    cmap="autumn",
+)
+
+# Add colorbar with logarithmic labels
+cb = fig.colorbar(splt.lines, ax=ax, label="|B| (mT)")
+ticks = np.array([3, 10, 30, 100, 300])
+cb.set_ticks(np.log10(ticks))
+cb.set_ticklabels(ticks)
+
+# Outline magnet boundary
+ax.plot(
+    [1, 1, -1, -1, 1],
+    [1, -1, -1, 1, 1],
+    "k--",
+    lw=2,
+)
+
+# Figure styling
+ax.set(
+    xlabel="x-position (mm)",
+    ylabel="z-position (mm)",
+)
+
+plt.tight_layout()
+plt.show()
+```
+
+```{note}
+Be aware that the above code is not very performant, but quite readable. The following example creates the grid with NumPy commands only instead of Python loops and uses the {ref}`examples-tutorial-field-computation-functional-interface` for field computation.
+```
+
+## Example 2 - Hollow Cylinder Magnet
+
+A nice visualization is achieved by combining `streamplot` with `contourf`. In this example we show the B-field of a hollow Cylinder magnet with diametral polarization in the xy-symmetry plane.
+
+```{code-cell} ipython3
+import matplotlib.pyplot as plt
+import numpy as np
+
+import magpylib as magpy
+
+# Create a Matplotlib figure
+fig, ax = plt.subplots()
+
+# Create an observer grid in the xy-symmetry plane - using pure numpy
+grid = np.mgrid[-.05:.05:100j, -.05:.05:100j, 0:0:1j].T[0]
+X, Y, _ = np.moveaxis(grid, 2, 0)
+
+# Compute magnetic field on grid - using the functional interface
+B = magpy.getB(
+    "CylinderSegment",
+    observers=grid.reshape(-1, 3),
+    dimension=(0.02, 0.03, 0.05, 0, 360),
+    polarization=(0.1, 0, 0),
+)
+B = B.reshape(grid.shape)
+Bx, By, _ = np.moveaxis(B, 2, 0)
+normB = np.linalg.norm(B, axis=2)
+
+# Combine streamplot with contourf
+cp = ax.contourf(X, Y, normB, cmap="rainbow", levels=100)
+splt = ax.streamplot(X, Y, Bx, By, color="k", density=1.5, linewidth=1)
+
+# Add colorbar
+fig.colorbar(cp, ax=ax, label="|B| (T)")
+
+# Outline magnet boundary
+ts = np.linspace(0, 2 * np.pi, 50)
+ax.plot(.03*np.cos(ts), .03*np.sin(ts), "w-", lw=2, zorder=2)
+ax.plot(.02*np.cos(ts), .02*np.sin(ts), "w-", lw=2, zorder=2)
+
+# Figure styling
+ax.set(
+    xlabel="x-position (m)",
+    ylabel="z-position (m)",
+    aspect=1,
+)
+
+plt.tight_layout()
+plt.show()
+```
diff --git a/docs/_pages/user_guide/examples/examples_vis_pv_streamlines.md b/docs/_pages/user_guide/examples/examples_vis_pv_streamlines.md
new file mode 100644
index 000000000..f4f8fdb22
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_vis_pv_streamlines.md
@@ -0,0 +1,73 @@
+---
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.16.0
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+orphan: true
+---
+
+(examples-vis-pv-streamlines)=
+
+# Pyvista 3D field lines
+
+Pyvista offers field-line computation and visualization in 3D. In addition to the field computation, Magpylib offers magnet visualization that seamlessly integrates into a Pyvista plotting scene.
+
+```{code-cell} ipython3
+import magpylib as magpy
+import pyvista as pv
+
+# Create a magnet with Magpylib
+magnet = magpy.magnet.Cylinder(polarization=(0, 0, 1), dimension=(0.010, 0.004))
+
+# Create a 3D grid with Pyvista
+grid = pv.ImageData(
+    dimensions=(41, 41, 41),
+    spacing=(0.001, 0.001, 0.001),
+    origin=(-0.02, -0.02, -0.02),
+)
+
+# Compute B-field and add as data to grid
+grid["B"] = magnet.getB(grid.points) * 1000  # T -> mT
+
+# Compute the field lines
+seed = pv.Disc(inner=0.001, outer=0.003, r_res=1, c_res=9)
+strl = grid.streamlines_from_source(
+    seed,
+    vectors="B",
+    max_step_length=0.1,
+    max_time=.02,
+    integration_direction="both",
+)
+
+# Create a Pyvista plotting scene
+pl = pv.Plotter()
+
+# Add magnet to scene - streamlines units are assumed to be meters
+magpy.show(magnet, canvas=pl, units_length="m", backend="pyvista")
+
+# Prepare legend parameters
+legend_args = {
+    "title": "B (mT)",
+    "title_font_size": 20,
+    "color": "black",
+    "position_y": 0.25,
+    "vertical": True,
+}
+
+# Add streamlines and legend to scene
+pl.add_mesh(
+    strl.tube(radius=0.0002),
+    cmap="bwr",
+    scalar_bar_args=legend_args,
+)
+
+# Prepare and show scene
+pl.camera.position = (0.03, 0.03, 0.03)
+pl.show()
+```
diff --git a/docs/_pages/user_guide/examples/examples_vis_subplots.md b/docs/_pages/user_guide/examples/examples_vis_subplots.md
new file mode 100644
index 000000000..9f926ed03
--- /dev/null
+++ b/docs/_pages/user_guide/examples/examples_vis_subplots.md
@@ -0,0 +1,99 @@
+---
+orphan: true
+jupytext:
+  text_representation:
+    extension: .md
+    format_name: myst
+    format_version: 0.13
+    jupytext_version: 1.14.5
+kernelspec:
+  display_name: Python 3
+  language: python
+  name: python3
+---
+
+(examples-vis-subplots)=
+
+# Subplots
+
+It is very illustrative to combine 2D and 3D subplots when viewing the field along paths. Consider the following system of a sensor and a magnet, both endowed with paths.
+
+```{code-cell} ipython3
+import numpy as np
+import magpylib as magpy
+
+# Define sensor with path
+cyl = magpy.magnet.Cylinder(
+    polarization=(1, 0, 0),
+    dimension=(2, 1),
+    position=(4,0,0),
+)
+cyl.rotate_from_angax(angle=np.linspace(0, 300, 40), start=0, axis="z", anchor=0)
+
+# Define magnet with path
+sens = magpy.Sensor(
+    pixel=[(-.2, 0, 0), (.2, 0, 0)],
+    position = np.linspace((0, 0, -3), (0, 0, 3), 40)
+)
+```
+
+In the following, we demonstrate various ways how to generate 2D/3D subplot combinations for this system.
+
+# Plotting canvas with own figure
+
+Customization is best done by adding the [Magpylib 3D-model](guide-graphics-show) to your own figure using the `canvas` kwarg.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+import matplotlib.pyplot as plt
+fig = plt.figure()
+ax1 = fig.add_subplot(121)
+ax2 = fig.add_subplot(122, projection='3d')
+
+# Show pixel1 field on ax1
+B = sens.getB(cyl)
+ax1.plot(B[:,0])
+
+# Place 3D plot on ax2
+magpy.show(sens, cyl, canvas=ax2)
+
+plt.show()
+```
+
+How to add and modify subplots in all three supported backends is demonstrated in the [canvas documentation](guide-graphics-canvas). It is also possible to customize the Magpylib 3D output by returning and editing the respective canvas using the `return_fig` kwarg, see [return figures](guide-graphics-return_fig).
+
+
+# Built-in subplots
+
+For maximal efficiency, Magpylib offers auto-generated subplots of 3D models and the field along paths by providing the `show` function with proper input dictionaries.
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+magpy.show(
+    {"objects": [cyl, sens], "output": "Bx", "col": 1},
+    {"objects": [cyl, sens], "output": "model3d", "col": 2},
+    backend='plotly',
+)
+```
+
+Detailed information on built-in subplots is provided in the [user guide](guide-graphics-subplots).
+
+# show_context
+
+With a built-in context manager this functionality can be accessed with maximal ease
+
+```{code-cell} ipython3
+# Continuation from above - ensure previous code is executed
+
+with magpy.show_context([cyl, sens], backend='plotly') as sc:
+    sc.show(output="Bx", col=1, row=1)
+    sc.show(output="By", col=1, row=2)
+    sc.show(output="Bz", col=2, row=1)
+    sc.show(output="model3d", col=2, row=2)
+```
+
+```{hint}
+A very powerful subplot-feature are the built-in [animated subplots](examples-vis-animated-subplots).
+```
diff --git a/docs/_pages/user_guide/examples/logo.stl b/docs/_pages/user_guide/examples/logo.stl
new file mode 100644
index 000000000..a9905648c
Binary files /dev/null and b/docs/_pages/user_guide/examples/logo.stl differ
diff --git a/docs/_pages/user_guide/guide_index.md b/docs/_pages/user_guide/guide_index.md
new file mode 100644
index 000000000..a63482e3d
--- /dev/null
+++ b/docs/_pages/user_guide/guide_index.md
@@ -0,0 +1,30 @@
+(guide-index)=
+# User Guide
+
+The user guide provides detailed descriptions of the Magpylib API with many interactive examples to help users along the way.
+
+```{toctree}
+:maxdepth: 2
+:caption: Getting Started
+guide_start_01_install.md
+guide_start_02_fundamentals.md
+```
+
+```{toctree}
+:maxdepth: 2
+:caption: Documentation
+docs/docs_units_types.md
+docs/docs_classes.md
+docs/docs_pos_ori.md
+docs/docs_fieldcomp.md
+docs/docs_graphics.md
+docs/docs_styles.md
+docs/docs_magpylib_force.md
+```
+
+```{toctree}
+:maxdepth: 2
+:caption: Resources
+guide_resources_01_physics.md
+examples/examples_index.md
+```
diff --git a/docs/_pages/user_guide/guide_resources_01_physics.md b/docs/_pages/user_guide/guide_resources_01_physics.md
new file mode 100644
index 000000000..07a0d2337
--- /dev/null
+++ b/docs/_pages/user_guide/guide_resources_01_physics.md
@@ -0,0 +1,123 @@
+(guide-ressources-physics)=
+# Physics and Computation
+
+## What is implemented ?
+
+The expressions used in Magpylib describe perfectly homogeneous magnets, surface charges, and line currents with natural boundary conditions. Magpylib is at its best when dealing with static air-coils (no eddy currents, no soft-magnetic cores) and high-grade permanent magnets (Ferrite, NdFeB, SmCo or similar materials). When **magnet** permeabilities are below $\mu_r < 1.1$ the error typically undercuts few %. Demagnetization factors are not included. The line **current** solutions give the exact same field as outside of a wire that carries a homogeneous current.
+
+## The analytical solutions
+
+Magnetic field computations in Magpylib are based on known analytical solutions (closed form expressions) to permanent magnet and current problems. The Magpylib implementations are based on the following literature references:
+
+- Field of cuboid magnets [^1] [^2] [^3] [^4]
+- Field of cylindrical magnets: [^5] [^6] [^7] [^8]
+- Field of triangular surfaces: [^9] [^10] [^11]
+- Field of the current loop: [^12]
+- all others derived by hand
+
+A short reflection on how these formulas can be achieved: In magnetostatics the magnetic field becomes conservative (Maxwell: $\nabla \times {\bf H} = 0$) and can thus be expressed through the magnetic scalar potential $\Phi_m$:
+
+$$
+{\bf H} = -\nabla\cdot\Phi_m
+$$
+
+The solution to this equation can be expressed by an integral over the magnetization distribution ${\bf M}({\bf r})$ as
+
+$$
+\Phi_m({\bf r}) = \frac{1}{4\pi}\int_{V'}\frac{\nabla'\cdot {\bf M}({\bf r}')}{|{\bf r}-{\bf r}'|}dV'+\frac{1}{4\pi}\oint_{S'}\frac{{\bf n}'\cdot {\bf M}({\bf r}')}{|{\bf r}-{\bf r}'|}dS'
+$$
+
+where ${\bf r}$ denotes the position, $V$ is the magnetized volume with surface $S$ and normal vector ${\bf n}$ onto the surface. This solution is derived in detail e.g. in [^13].
+
+The fields of currents are directly derived using the law of Biot-Savart with the current distribution ${\bf J}({\bf r})$:
+
+$$
+{\bf B}({\bf r}) = \frac{\mu_0}{4\pi}\int_{V'} {\bf J}({\bf r}')\times \frac{{\bf r}-{\bf r}'}{|{\bf r}-{\bf r}'|^3} dV'
+$$
+
+In some special cases (simple shapes, homogeneous magnetisations, and current distributions) the above integrals can be worked out directly to give analytical formulas (or simple, fast converging series). The derivations can be found in the respective references. A noteworthy comparison between the Coulombian approach and the Amperian current model is given in [^14].
+
+## Accuracy of the Solutions and Demagnetization
+
+### Line currents:
+
+The magnetic field of a wire carrying a homogeneous current density is (on the outside) similar to the field of a line current positioned in the center of the wire, which carries the same total current. Current distributions become inhomogeneous at bends of the wire or when eddy currents (finite frequencies) are involved.
+
+(guide-physics-demag)=
+### Magnets and Demagnetization
+
+The analytical solutions are exact when bodies have a homogeneous magnetization. However, real materials always have a material response which results in an inhomogeneous magnetization even when the initial magnetization is perfectly homogeneous. There is a lot of literature on such [demagnetization effects](https://en.wikipedia.org/wiki/Demagnetizing_field).
+
+Modern high grade permanent magnets (NdFeB, SmCo, Ferrite) have a very weak material responses (local slope of the magnetization curve, remanent permeability) of the order of $\mu_r \approx 1.05$. In this case the analytical solutions provide an excellent approximation with less than 1% error even at close distance from the magnet surface. A detailed error analysis and discussion is presented in the appendix of [^15].
+
+Error estimation as a result of the material response is evaluated in more detail in the appendix of [Malagò2020](https://www.mdpi.com/1424-8220/20/23/6873).
+
+Demagnetization factors can be used to compensate a large part of the demagnetization effect. Analytical expressions for the demagnetization factors of cuboids can be found at [magpar.net](http://www.magpar.net/static/magpar/doc/html/demagcalc.html).
+
+
+(phys-remanence)=
+### Modeling a datasheet magnet
+
+The material remanence, often found in data sheets, simply corresponds to the material magnetization/polarization when not under the influence of external fields. This can never happen, as the material itself generates a magnetic field. Such self-interactions result in self-demagnetization that can be approximated using the demagnetization factors and the material permeability (or susceptibility).
+
+For example, a cube with 1 mm sides has a demagnetization factor is 0.333, see [magpar.net](http://www.magpar.net/static/magpar/doc/html/demagcalc.html). When the remanence field of this cube is 1 T, and its susceptibility is 0.1, the magnetization resulting from self-interaction is reduced to 1 T - 0.3333*0.1 T = 0.9667 T, assuming linear material laws.
+
+A [tutorial](examples-tutorial-modeling-magnets) explains how to deal with demagnetization effects and how real magnets can be modeled using datasheet values.
+
+It must be understood that the change in magnetization resulting from self-interaction has a homogeneous contribution which is approximated by the demagnetization factor, and an inhomogeneous contribution which cannot be modeled easily with analytical solutions. The inhomogeneous part, however, is typically an order of magnitude lower than the homogeneous part. You can use the Magpylib extension [Magpylib material response](https://github.com/magpylib/magpylib-material-response) to model the self-interactions.
+
+### Soft-Magnetic Materials
+
+Soft-magnetic materials like iron or steel with large permeabilities $\mu_r \sim 1000$ and low remanence fields are dominated by the material response. It is not possible to describe such bodies with analytical solutions. However, recent developments showed that the **Magnetostatic Method of Moments** can be a powerful tool in combination with Magpylib to compute such a material response. An integration into Magpylib is planned in the future.
+
+When a magnet lies in front of a soft-magnetic plate, the contribution from the plate can be modeled with high accuracy using a **mirror**-approach, similar to the electrostatic "mirror charge".
+
+
+(docu-performance)=
+## Computation Performance
+
+Magpylib code is fully [vectorized](https://en.wikipedia.org/wiki/Array_programming), written almost completely in numpy native. Magpylib automatically vectorizes the computation with complex inputs (many sources, many observers, paths) and never falls back on using loops.
+
+```{note}
+Maximal performance is achieved when `.getB(sources, observers)` is called only a single time in your program. Try not to use loops.
+```
+
+The object-oriented interface comes with an overhead. If you want to achieve maximal performance this overhead can be avoided with {ref}`docs-field-functional`.
+
+The analytical solutions provide extreme performance. Single field evaluations take of the order of `100 µs`. For large input arrays (e.g. many observer positions or many similar magnets) the computation time can drop below `1 µs` per evaluation point on single state-of-the-art x86 mobile cores (tested on `Intel Core i5-8365U @ 1.60GHz`), depending on the source type.
+
+## Numerical stability
+
+Many expressions provided in the literature have very questionable numerical stability. Many of these problems are fixed in Magpylib, but one should be aware that accuracy can be a problem very close to objects, close the z-axis in cylindrical symmetries, at edge extensions, and at large distances. We are working on fixing these problems. Some details can be found in [^12].
+
+**References**
+
+[^1]: Z. J. Yang et al., "Potential and force between a magnet and a bulk Y1Ba2Cu3O7-d superconductor studied by a mechanical pendulum", Superconductor Science and Technology 3(12):591, 1990
+
+[^2]: R. Engel-Herbert et al., Journal of Applied Physics 97(7):074504 - 074504-4 (2005)
+
+[^3]: J.M. Camacho and V. Sosa, "Alternative method to calculate the magnetic field of permanent magnets with azimuthal symmetry", Revista Mexicana de Fisica E 59 8–17, 2013
+
+[^4]: D. Cichon, R. Psiuk and H. Brauer, "A Hall-Sensor-Based Localization Method With Six Degrees of Freedom Using Unscented Kalman Filter", IEEE Sensors Journal, Vol. 19, No. 7, April 1, 2019.
+
+[^5]: E. P. Furlani, S. Reanik and W. Janson, "A Three-Dimensional Field Solution for Bipolar Cylinders", IEEE Transaction on Magnetics, VOL. 30, NO. 5, 1994
+
+[^6]: N. Derby, "Cylindrical Magnets and Ideal Solenoids", arXiv:0909.3880v1, 2009
+
+[^7]: A. Caciagli, R. J. Baars, A. P. Philipse and B. W. M. Kuipers, "Exact expression for the magnetic field of a finite cylinder with arbitrary uniform magnetization", Journal of Magnetism and Magnetic Materials 456 (2018) 423–432.
+
+[^8]: F. Slanovc, M. Ortner, M. Moridi, C. Abert and D. Suess, "Full analytical solution for the magnetic field of uniformly magnetized cylinder tiles", submitted to Journal of Magnetism and Magnetic Materials.
+
+[^9]: D. Guptasarma and B. Singh, "New scheme for computing the magnetic field resulting from a uniformly magnetized arbitrary polyhedron", Geophysics (1999), 64(1):70.
+
+[^10]: J.L.G. Janssen, J.J.H. Paulides and E.A. Lomonova, "3D ANALYTICAL FIELD CALCULATION USING TRIANGULAR MAGNET SEGMENTS APPLIED TO A SKEWED LINEAR PERMANENT MAGNET ACTUATOR", ISEF 2009 - XIV International Symposium on Electromagnetic Fields in Mechatronics, Electrical and Electronic Engineering Arras, France, September 10-12, 2009
+
+[^11]: C. Rubeck et al., "Analytical Calculation of Magnet Systems: Magnetic Field Created by Charged Triangles and Polyhedra", IEEE Transactions on Magnetics, VOL. 49, NO. 1, 2013
+
+[^12]: M. Ortner, S. Slanovc and P. Leitner, "Numerically Stable and Computationally Efficient Expression for the Magnetic Field of a Current Loop", MDPI Magnetism, 3(1), 11-31, 2022.
+
+[^13]: J. D. Jackson, "Classical Electrodynamics", 1999 Wiley, New York
+
+[^14]: R. Ravaud and G. Lamarquand, "Comparison of the coulombian and amperian current models for calculating the magnetic field produced by radially magnetized arc-shaped permanent magnets", HAL Id: hal-00412346
+
+[^15]: P. Malagò et al., Magnetic Position System Design Method Applied to Three-Axis Joystick Motion Tracking. Sensors, 2020, 20. Jg., Nr. 23, S. 6873.
diff --git a/docs/_pages/user_guide/guide_start_01_install.md b/docs/_pages/user_guide/guide_start_01_install.md
new file mode 100644
index 000000000..e8f1473e8
--- /dev/null
+++ b/docs/_pages/user_guide/guide_start_01_install.md
@@ -0,0 +1,29 @@
+# Installation and Dependencies
+
+## Dependencies and Synergies
+
+Magpylib supports *Python3.10+* and relies on common scientific computation libraries *NumPy* and *Scipy* for computation, and *Matplotlib* and *Plotly* for graphic outputs.
+
+Optionally, *Pyvista* is supported as graphical backend, as well as *Pandas* for the field computation output.
+
+## Installation
+
+::::{grid} 1 1 2 2
+:margin: 4 4 0 0
+:gutter: 4
+
+:::{grid-item-card} Install with pip:
+:text-align: center
+:shadow: none
+```console
+pip install magpylib
+```
+:::
+:::{grid-item-card} Install with conda:
+:text-align: center
+:shadow: none
+```console
+conda install -c conda-forge magpylib
+```
+:::
+::::
diff --git a/docs/_pages/user_guide/guide_start_02_fundamentals.md b/docs/_pages/user_guide/guide_start_02_fundamentals.md
new file mode 100644
index 000000000..1d2dba684
--- /dev/null
+++ b/docs/_pages/user_guide/guide_start_02_fundamentals.md
@@ -0,0 +1,264 @@
+(getting-started)=
+# The Magpylib fundamentals
+
+In this section we present the most important Magpylib features, focussing on the intuitive object-oriented interface.
+
+## Basic features
+
+Learn the Magpylib fundamentals (create magnets, view system, compute field) in 5 minutes. This requires a basic understanding of the Python programming language, the [NumPy array class](https://numpy.org/doc/stable/) and the [Scipy Rotation class](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html).
+
+```{hint}
+Since v5 all Magpylib inputs and outputs are by default in SI-units. See {ref}`guide-docs-io-scale-invariance` for convenient use.
+```
+
+### Create sources and observers as Python objects
+
+In the object oriented interface sources of the magnetic field (magnets, currents, others) and observers of the magnetic field (sensors) are created as Python objects.
+
+```python
+import magpylib as magpy
+
+# Create a Cuboid magnet with magnetic polarization
+# of 1 T pointing in x-direction and sides of
+# 1,2 and 3 cm respectively (notice the use of SI units).
+
+cube = magpy.magnet.Cuboid(polarization=(1, 0, 0), dimension=(0.01, 0.02, 0.03))
+
+# Create a Sensor for measuring the field
+
+sensor = magpy.Sensor()
+```
+
+Find detailed information on the Magpylib classes [here](docs-classes).
+
+### Position and orientation
+
+All Magpylib objects (sources and observers) have position and orientation in a global Cartesian coordinate system that can be manipulated.
+
+```python
+# By default, the position of a Magpylib object is
+# (0,0,0) and its orientation is the unit rotation,
+# given by a scipy rotation object.
+
+print(cube.position)  # -> [0. 0. 0.]
+print(cube.orientation.as_rotvec())  # -> [0. 0. 0.]
+
+# Manipulate object position and orientation through
+# the respective attributes (move 10 mm and rotate 45 deg):
+
+from scipy.spatial.transform import Rotation as R
+
+cube.position = (0.01, 0, 0)
+cube.orientation = R.from_rotvec((0, 0, 45), degrees=True)
+
+print(cube.position)  # -> [0.01 0.   0.  ]
+print(cube.orientation.as_rotvec(degrees=True))  # -> [0. 0. 45.]
+
+# Apply relative motion with the powerful `move`
+# and `rotate` methods.
+sensor.move((-0.01, 0, 0))
+sensor.rotate_from_angax(angle=-45, axis="z")
+
+print(sensor.position)  # -> [-0.01  0.    0.  ]
+print(sensor.orientation.as_rotvec(degrees=True))  # -> [ 0.  0. -45.]
+```
+
+Find detailed information on position and orientation attributes and how to manipulate them [here](docs-position).
+
+### 3D view of objects
+
+In-built 3D graphic output helps to see if all Magpylib objects are positioned properly. The magnet polarization is represented by default by a 3-color scheme, the sensor by an axes cross.
+
+```python
+# Use the `show` function to view your system
+# through Matplotlib, Plotly or Pyvista backends.
+
+magpy.show(cube, sensor, backend="plotly")
+```
+
+<img src="../../_static/images/getting_started_fundamentals1.png" width=50% align="center">
+
+Detailed information on the graphical output with `show` is given [here](guide-graphics).
+
+### Computing the field
+
+The field can be computed at sensor objects, or simply by specifying a position of interest.
+
+```python
+# Compute the B-field for some positions.
+
+points = [(0, 0, -0.01), (0, 0, 0), (0, 0, 0.01)]  # in SI Units (m)
+B = magpy.getB(cube, points)
+
+print(B.round(2))  # -> [[ 0.26  0.07  0.08]
+#     [ 0.28  0.05  0.  ]
+#     [ 0.26  0.07 -0.08]] # in SI Units (T)
+
+# Compute the H-field at the sensor.
+
+H = magpy.getH(cube, sensor)
+
+print(H.round())  # -> [51017. 24210.     0.] # in SI Units (A/m)
+```
+
+```{hint}
+Magpylib makes use of vectorized computation (massive speedup). This requires that you hand over all field computation instances (multiple objects with multiple positions (=paths)) at the same time to `getB`, `getH`, `getJ` and `getM`. Avoid Python loops at all costs !!!
+```
+
+Detailed information on field computation is provided [here](docs-fieldcomp).
+
+## Advanced features
+
+While most things can be achieved with the above, the following features will make your live much easier.
+
+### Paths
+
+Magpylib position and orientation attributes can store multiple values that are referred to as paths. The field will automatically be computed for all path positions. Use this feature to model objects that move to multiple locations.
+
+```python
+import numpy as np
+import magpylib as magpy
+
+# Create magnet
+sphere = magpy.magnet.Sphere(diameter=0.01, polarization=(0, 0, 1))
+
+# Assign a path
+sphere.position = np.linspace((-0.02, 0, 0), (0.02, 0, 0), 7)
+
+# The field is automatically computed for every path position
+B = sphere.getB((0, 0, 0.01))
+print(B.round(3))  # ->[[ 0.004  0.    -0.001]
+# [ 0.013  0.     0.001]
+# [ 0.033  0.     0.026]
+# [ 0.     0.     0.083]
+# [-0.033  0.     0.026]
+# [-0.013  0.     0.001]
+# [-0.004  0.    -0.001]]
+```
+
+More information on paths is provided [here](docs-position).
+
+### Collections
+Magpylib objects can be grouped into Collections. An operation applied to a Collection is applied to every object in it. The Collection itself behaves like a single source object.
+
+```python
+import magpylib as magpy
+
+# Create objects
+obj1 = magpy.Sensor()
+obj2 = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(0.01, 0.02, 0.03))
+
+# Group objects
+coll = magpy.Collection(obj1, obj2)
+
+# Manipulate Collection
+coll.move((0.001, 0.002, 0.003))
+
+print(obj1.position)  # -> [0.001 0.002 0.003]
+print(obj2.position)  # -> [0.001 0.002 0.003]
+```
+
+Collections are discussed in detail [here](guide-docs-classes-collections).
+
+### Complex Magnet Shapes
+There most convenient way to create a magnet with complex shape is by using the convex hull of a point cloud (= simplest geometric form that includes all given points) and transform it into a triangular surface mesh.
+
+```python
+import numpy as np
+
+import magpylib as magpy
+
+# Create a Pyramid magnet
+points = np.array(
+    [
+        (-1, -1, 0),
+        (-1, 1, 0),
+        (1, -1, 0),
+        (1, 1, 0),
+        (0, 0, 2),
+    ]
+)
+pyramid = magpy.magnet.TriangularMesh.from_ConvexHull(
+    magnetization=(0, 0, 1e6),
+    points=points,
+)
+
+# Display the magnet graphically
+pyramid.show()
+```
+<img src="../../_static/images/getting_started_complex_shapes.png" width=50% align="center">
+
+There are several other possibilities to create complex magnet shapes. Some can be found in the [examples](examples-complex-magnet-shapes).
+
+
+### Graphic Styles
+Magpylib offers many ways to customize the graphic output.
+
+```python
+import magpylib as magpy
+
+# Create Cuboid magnet with custom style
+cube = magpy.magnet.Cuboid(
+    polarization=(0, 0, 1),
+    dimension=(0.01, 0.01, 0.01),
+    style_color="r",
+    style_magnetization_mode="arrow",
+)
+
+# Create Cylinder magnet with custom style
+cyl = magpy.magnet.Cylinder(
+    polarization=(0, 0, 1),
+    dimension=(0.01, 0.01),
+    position=(0.02, 0, 0),
+    style_magnetization_color_mode="bicolor",
+    style_magnetization_color_north="m",
+    style_magnetization_color_south="c",
+)
+magpy.show(cube, cyl)
+```
+<img src="../../_static/images/getting_started_styles.png" width=50% align="center">
+
+The many options for graphic styling can be found [here](guide-graphic-styles).
+
+### Animation
+Object paths can be animated. For this feature the plotly graphic backend is recommended.
+
+```python
+import numpy as np
+import magpylib as magpy
+
+
+# Create magnet with path
+cube = magpy.magnet.Cuboid(
+    magnetization=(0, 0, 1),
+    dimension=(1, 1, 1),
+)
+cube.rotate_from_angax(angle=np.linspace(10, 360, 18), axis="x")
+
+# Generate an animation with `show`
+cube.show(animation=True, backend="plotly")
+```
+<img src="../../_static/images/getting_started_animation.png" width=50% align="center">
+
+Nice animation examples are shown [here](examples-vis-animations), and a detailed discussion is provided [here](guide-graphic-animations).
+
+### Functional interface
+Magpylib's object oriented interface is convenient to work with but is also slowed down by object initialization and handling. The functional interface bypasses this load and enables fast field computation for an arbitrary set of input parameters.
+
+```python
+import magpylib as magpy
+
+# Compute the magnetic field via the functional interface.
+B = magpy.getB(
+    sources="Cuboid",
+    observers=[(-1, 0, 1), (0, 0, 1), (1, 0, 1)],
+    dimension=(1, 1, 1),
+    polarization=(0, 0, 1),
+)
+
+print(B.round(3))  # -> [[-0.043  0.     0.014]
+# [ 0.     0.     0.135]
+# [ 0.043  0.     0.014]]
+```
+
+Details on the functional interface are found [here](docs-field-functional).
diff --git a/docs/_static/css/stylesheet.css b/docs/_static/css/stylesheet.css
deleted file mode 100644
index 609a61f6f..000000000
--- a/docs/_static/css/stylesheet.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.center {
-    text-align: center
-}
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 000000000..d699c9c38
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,39 @@
+#galleryimg {
+    background: red;
+}
+
+/* fix image transparency in docu WTF? */
+html[data-theme=dark] .bd-content img:not(.only-dark):not(.dark-light) {
+    background: none;
+}
+
+/* custom padding for index page*/
+.sectiontext {
+    margin-top:-40px;
+}
+
+
+/*
+hide vertical scrollbar - it always appears unwanted when there
+is no content
+*/
+
+.bd-sidebar {
+    overflow-y: auto;  /* Change to auto to only show scrollbar when necessary */
+}
+
+
+
+/* Adjust the height to ensure it fits within the viewport */
+/* .bd-sidebar {
+    max-height: calc(100vh - var(--header-height, 0px));
+} */
+
+
+/*
+Target the logo text
+*/
+.navbar-brand {
+    font-weight: bold;
+    font-size: 1.6em;
+}
diff --git a/docs/_static/images/docu_classes_init_collection.png b/docs/_static/images/docu_classes_init_collection.png
new file mode 100644
index 000000000..f5c9ea143
Binary files /dev/null and b/docs/_static/images/docu_classes_init_collection.png differ
diff --git a/docs/_static/images/docu_classes_init_cuboid.png b/docs/_static/images/docu_classes_init_cuboid.png
new file mode 100644
index 000000000..4b5e1d372
Binary files /dev/null and b/docs/_static/images/docu_classes_init_cuboid.png differ
diff --git a/docs/_static/images/docu_classes_init_custom.png b/docs/_static/images/docu_classes_init_custom.png
new file mode 100644
index 000000000..e00b5036d
Binary files /dev/null and b/docs/_static/images/docu_classes_init_custom.png differ
diff --git a/docs/_static/images/docu_classes_init_cylinder.png b/docs/_static/images/docu_classes_init_cylinder.png
new file mode 100644
index 000000000..9e00fc544
Binary files /dev/null and b/docs/_static/images/docu_classes_init_cylinder.png differ
diff --git a/docs/_static/images/docu_classes_init_cylindersegment.png b/docs/_static/images/docu_classes_init_cylindersegment.png
new file mode 100644
index 000000000..73350d008
Binary files /dev/null and b/docs/_static/images/docu_classes_init_cylindersegment.png differ
diff --git a/docs/_static/images/docu_classes_init_dipole.png b/docs/_static/images/docu_classes_init_dipole.png
new file mode 100644
index 000000000..90a859558
Binary files /dev/null and b/docs/_static/images/docu_classes_init_dipole.png differ
diff --git a/docs/_static/images/docu_classes_init_global_local.png b/docs/_static/images/docu_classes_init_global_local.png
new file mode 100644
index 000000000..38f23f91c
Binary files /dev/null and b/docs/_static/images/docu_classes_init_global_local.png differ
diff --git a/docs/_static/images/docu_classes_init_line.png b/docs/_static/images/docu_classes_init_line.png
new file mode 100644
index 000000000..54530886f
Binary files /dev/null and b/docs/_static/images/docu_classes_init_line.png differ
diff --git a/docs/_static/images/docu_classes_init_loop.png b/docs/_static/images/docu_classes_init_loop.png
new file mode 100644
index 000000000..09502024b
Binary files /dev/null and b/docs/_static/images/docu_classes_init_loop.png differ
diff --git a/docs/_static/images/docu_classes_init_sensor.png b/docs/_static/images/docu_classes_init_sensor.png
new file mode 100644
index 000000000..9a529585c
Binary files /dev/null and b/docs/_static/images/docu_classes_init_sensor.png differ
diff --git a/docs/_static/images/docu_classes_init_sphere.png b/docs/_static/images/docu_classes_init_sphere.png
new file mode 100644
index 000000000..69e05523d
Binary files /dev/null and b/docs/_static/images/docu_classes_init_sphere.png differ
diff --git a/docs/_static/images/docu_classes_init_tetra.png b/docs/_static/images/docu_classes_init_tetra.png
new file mode 100644
index 000000000..9e0c030ba
Binary files /dev/null and b/docs/_static/images/docu_classes_init_tetra.png differ
diff --git a/docs/_static/images/docu_classes_init_triangle.png b/docs/_static/images/docu_classes_init_triangle.png
new file mode 100644
index 000000000..ff2e8f5db
Binary files /dev/null and b/docs/_static/images/docu_classes_init_triangle.png differ
diff --git a/docs/_static/images/docu_classes_init_trimesh.png b/docs/_static/images/docu_classes_init_trimesh.png
new file mode 100644
index 000000000..e142afa87
Binary files /dev/null and b/docs/_static/images/docu_classes_init_trimesh.png differ
diff --git a/docs/_static/images/docu_field_comp_flow.png b/docs/_static/images/docu_field_comp_flow.png
new file mode 100644
index 000000000..11ff11666
Binary files /dev/null and b/docs/_static/images/docu_field_comp_flow.png differ
diff --git a/docs/_static/images/docu_field_superpos_cutout.png b/docs/_static/images/docu_field_superpos_cutout.png
new file mode 100644
index 000000000..88378c8b0
Binary files /dev/null and b/docs/_static/images/docu_field_superpos_cutout.png differ
diff --git a/docs/_static/images/docu_field_superpos_union.png b/docs/_static/images/docu_field_superpos_union.png
new file mode 100644
index 000000000..06caf2f7a
Binary files /dev/null and b/docs/_static/images/docu_field_superpos_union.png differ
diff --git a/docs/_static/images/docu_position_sketch.png b/docs/_static/images/docu_position_sketch.png
new file mode 100644
index 000000000..050cb90bb
Binary files /dev/null and b/docs/_static/images/docu_position_sketch.png differ
diff --git a/docs/_static/images/documentation/Collection_Display.JPG b/docs/_static/images/documentation/Collection_Display.JPG
deleted file mode 100644
index 51a755667..000000000
Binary files a/docs/_static/images/documentation/Collection_Display.JPG and /dev/null differ
diff --git a/docs/_static/images/documentation/SourceTypes.png b/docs/_static/images/documentation/SourceTypes.png
deleted file mode 100644
index 2971850fe..000000000
Binary files a/docs/_static/images/documentation/SourceTypes.png and /dev/null differ
diff --git a/docs/_static/images/documentation/Source_Display.JPG b/docs/_static/images/documentation/Source_Display.JPG
deleted file mode 100644
index 324e50861..000000000
Binary files a/docs/_static/images/documentation/Source_Display.JPG and /dev/null differ
diff --git a/docs/_static/images/documentation/collectionAnalysis.png b/docs/_static/images/documentation/collectionAnalysis.png
deleted file mode 100644
index 562fdd313..000000000
Binary files a/docs/_static/images/documentation/collectionAnalysis.png and /dev/null differ
diff --git a/docs/_static/images/documentation/collectionExample.gif b/docs/_static/images/documentation/collectionExample.gif
deleted file mode 100644
index a411cf539..000000000
Binary files a/docs/_static/images/documentation/collectionExample.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/examplePlot.jpg b/docs/_static/images/documentation/examplePlot.jpg
deleted file mode 100644
index 6d91cf9e7..000000000
Binary files a/docs/_static/images/documentation/examplePlot.jpg and /dev/null differ
diff --git a/docs/_static/images/documentation/getBsweep.png b/docs/_static/images/documentation/getBsweep.png
deleted file mode 100644
index 521578ed6..000000000
Binary files a/docs/_static/images/documentation/getBsweep.png and /dev/null differ
diff --git a/docs/_static/images/documentation/lib_structure.png b/docs/_static/images/documentation/lib_structure.png
deleted file mode 100644
index 34fe39786..000000000
Binary files a/docs/_static/images/documentation/lib_structure.png and /dev/null differ
diff --git a/docs/_static/images/documentation/move.gif b/docs/_static/images/documentation/move.gif
deleted file mode 100644
index 9e182c413..000000000
Binary files a/docs/_static/images/documentation/move.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/rotate.gif b/docs/_static/images/documentation/rotate.gif
deleted file mode 100644
index a112f25ae..000000000
Binary files a/docs/_static/images/documentation/rotate.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/setOrientation.gif b/docs/_static/images/documentation/setOrientation.gif
deleted file mode 100644
index 7186dff28..000000000
Binary files a/docs/_static/images/documentation/setOrientation.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/setPosition.gif b/docs/_static/images/documentation/setPosition.gif
deleted file mode 100644
index 0a691887a..000000000
Binary files a/docs/_static/images/documentation/setPosition.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/sourceGeometry.png b/docs/_static/images/documentation/sourceGeometry.png
deleted file mode 100644
index d452cfa6a..000000000
Binary files a/docs/_static/images/documentation/sourceGeometry.png and /dev/null differ
diff --git a/docs/_static/images/documentation/sourceOrientation.png b/docs/_static/images/documentation/sourceOrientation.png
deleted file mode 100644
index e03e6aaee..000000000
Binary files a/docs/_static/images/documentation/sourceOrientation.png and /dev/null differ
diff --git a/docs/_static/images/documentation/sourceVarsMethods.png b/docs/_static/images/documentation/sourceVarsMethods.png
deleted file mode 100644
index 792e7d9fa..000000000
Binary files a/docs/_static/images/documentation/sourceVarsMethods.png and /dev/null differ
diff --git a/docs/_static/images/documentation/superposition.png b/docs/_static/images/documentation/superposition.png
deleted file mode 100644
index 5ebc61c08..000000000
Binary files a/docs/_static/images/documentation/superposition.png and /dev/null differ
diff --git a/docs/_static/images/documentation/sweep1.gif b/docs/_static/images/documentation/sweep1.gif
deleted file mode 100644
index 8147566f0..000000000
Binary files a/docs/_static/images/documentation/sweep1.gif and /dev/null differ
diff --git a/docs/_static/images/documentation/sweep2.gif b/docs/_static/images/documentation/sweep2.gif
deleted file mode 100644
index a3b4e8564..000000000
Binary files a/docs/_static/images/documentation/sweep2.gif and /dev/null differ
diff --git a/docs/_static/images/examples/JoystickExample1.JPG b/docs/_static/images/examples/JoystickExample1.JPG
deleted file mode 100644
index e31edd33e..000000000
Binary files a/docs/_static/images/examples/JoystickExample1.JPG and /dev/null differ
diff --git a/docs/_static/images/examples/JoystickExample2.JPG b/docs/_static/images/examples/JoystickExample2.JPG
deleted file mode 100644
index dc2569bcc..000000000
Binary files a/docs/_static/images/examples/JoystickExample2.JPG and /dev/null differ
diff --git a/docs/_static/images/examples_force_floating_coil-magnet.png b/docs/_static/images/examples_force_floating_coil-magnet.png
new file mode 100644
index 000000000..9ed8a1e56
Binary files /dev/null and b/docs/_static/images/examples_force_floating_coil-magnet.png differ
diff --git a/docs/_static/images/examples_force_floating_ringdown.png b/docs/_static/images/examples_force_floating_ringdown.png
new file mode 100644
index 000000000..adf89abeb
Binary files /dev/null and b/docs/_static/images/examples_force_floating_ringdown.png differ
diff --git a/docs/_static/images/examples_force_holding_force.png b/docs/_static/images/examples_force_holding_force.png
new file mode 100644
index 000000000..e6cb65fc1
Binary files /dev/null and b/docs/_static/images/examples_force_holding_force.png differ
diff --git a/docs/_static/images/examples_icon_WIP.png b/docs/_static/images/examples_icon_WIP.png
new file mode 100644
index 000000000..5d592d851
Binary files /dev/null and b/docs/_static/images/examples_icon_WIP.png differ
diff --git a/docs/_static/images/examples_icon_app_end_of_shaft.png b/docs/_static/images/examples_icon_app_end_of_shaft.png
new file mode 100644
index 000000000..99c401317
Binary files /dev/null and b/docs/_static/images/examples_icon_app_end_of_shaft.png differ
diff --git a/docs/_static/images/examples_icon_app_halbach.png b/docs/_static/images/examples_icon_app_halbach.png
new file mode 100644
index 000000000..ae2317c27
Binary files /dev/null and b/docs/_static/images/examples_icon_app_halbach.png differ
diff --git a/docs/_static/images/examples_icon_app_helmholtz.png b/docs/_static/images/examples_icon_app_helmholtz.png
new file mode 100644
index 000000000..927185de5
Binary files /dev/null and b/docs/_static/images/examples_icon_app_helmholtz.png differ
diff --git a/docs/_static/images/examples_icon_ext_custom_quadrupole.png b/docs/_static/images/examples_icon_ext_custom_quadrupole.png
new file mode 100644
index 000000000..49f4cedff
Binary files /dev/null and b/docs/_static/images/examples_icon_ext_custom_quadrupole.png differ
diff --git a/docs/_static/images/examples_icon_force_floating.png b/docs/_static/images/examples_icon_force_floating.png
new file mode 100644
index 000000000..a16405182
Binary files /dev/null and b/docs/_static/images/examples_icon_force_floating.png differ
diff --git a/docs/_static/images/examples_icon_force_force.png b/docs/_static/images/examples_icon_force_force.png
new file mode 100644
index 000000000..45304297a
Binary files /dev/null and b/docs/_static/images/examples_icon_force_force.png differ
diff --git a/docs/_static/images/examples_icon_force_holding_force.png b/docs/_static/images/examples_icon_force_holding_force.png
new file mode 100644
index 000000000..ce223bbfe
Binary files /dev/null and b/docs/_static/images/examples_icon_force_holding_force.png differ
diff --git a/docs/_static/images/examples_icon_misc_compound.png b/docs/_static/images/examples_icon_misc_compound.png
new file mode 100644
index 000000000..d59e657d6
Binary files /dev/null and b/docs/_static/images/examples_icon_misc_compound.png differ
diff --git a/docs/_static/images/examples_icon_misc_field_interpolation.png b/docs/_static/images/examples_icon_misc_field_interpolation.png
new file mode 100644
index 000000000..ae02969e5
Binary files /dev/null and b/docs/_static/images/examples_icon_misc_field_interpolation.png differ
diff --git a/docs/_static/images/examples_icon_misc_inhom.png b/docs/_static/images/examples_icon_misc_inhom.png
new file mode 100644
index 000000000..18c83294b
Binary files /dev/null and b/docs/_static/images/examples_icon_misc_inhom.png differ
diff --git a/docs/_static/images/examples_icon_shapes_cad.png b/docs/_static/images/examples_icon_shapes_cad.png
new file mode 100644
index 000000000..22b85d83b
Binary files /dev/null and b/docs/_static/images/examples_icon_shapes_cad.png differ
diff --git a/docs/_static/images/examples_icon_shapes_convex_hull.png b/docs/_static/images/examples_icon_shapes_convex_hull.png
new file mode 100644
index 000000000..cbab713e4
Binary files /dev/null and b/docs/_static/images/examples_icon_shapes_convex_hull.png differ
diff --git a/docs/_static/images/examples_icon_shapes_pyvista.png b/docs/_static/images/examples_icon_shapes_pyvista.png
new file mode 100644
index 000000000..d5de5bdee
Binary files /dev/null and b/docs/_static/images/examples_icon_shapes_pyvista.png differ
diff --git a/docs/_static/images/examples_icon_shapes_superpos.png b/docs/_static/images/examples_icon_shapes_superpos.png
new file mode 100644
index 000000000..35013a08c
Binary files /dev/null and b/docs/_static/images/examples_icon_shapes_superpos.png differ
diff --git a/docs/_static/images/examples_icon_shapes_triangle.png b/docs/_static/images/examples_icon_shapes_triangle.png
new file mode 100644
index 000000000..3e8649311
Binary files /dev/null and b/docs/_static/images/examples_icon_shapes_triangle.png differ
diff --git a/docs/_static/images/examples_icon_tutorial_collection.png b/docs/_static/images/examples_icon_tutorial_collection.png
new file mode 100644
index 000000000..0ac472ab4
Binary files /dev/null and b/docs/_static/images/examples_icon_tutorial_collection.png differ
diff --git a/docs/_static/images/examples_icon_tutorial_custom.png b/docs/_static/images/examples_icon_tutorial_custom.png
new file mode 100644
index 000000000..6d876c67d
Binary files /dev/null and b/docs/_static/images/examples_icon_tutorial_custom.png differ
diff --git a/docs/_static/images/examples_icon_tutorial_field_computation.png b/docs/_static/images/examples_icon_tutorial_field_computation.png
new file mode 100644
index 000000000..78f4257f5
Binary files /dev/null and b/docs/_static/images/examples_icon_tutorial_field_computation.png differ
diff --git a/docs/_static/images/examples_icon_tutorial_modeling_magnets.png b/docs/_static/images/examples_icon_tutorial_modeling_magnets.png
new file mode 100644
index 000000000..7d88c7018
Binary files /dev/null and b/docs/_static/images/examples_icon_tutorial_modeling_magnets.png differ
diff --git a/docs/_static/images/examples_icon_tutorial_paths.png b/docs/_static/images/examples_icon_tutorial_paths.png
new file mode 100644
index 000000000..7fe965995
Binary files /dev/null and b/docs/_static/images/examples_icon_tutorial_paths.png differ
diff --git a/docs/_static/images/examples_icon_vis_animations.png b/docs/_static/images/examples_icon_vis_animations.png
new file mode 100644
index 000000000..fc99ab2be
Binary files /dev/null and b/docs/_static/images/examples_icon_vis_animations.png differ
diff --git a/docs/_static/images/examples_icon_vis_magnet_colors.png b/docs/_static/images/examples_icon_vis_magnet_colors.png
new file mode 100644
index 000000000..0bcb52b45
Binary files /dev/null and b/docs/_static/images/examples_icon_vis_magnet_colors.png differ
diff --git a/docs/_static/images/examples_icon_vis_mpl_streamplot.png b/docs/_static/images/examples_icon_vis_mpl_streamplot.png
new file mode 100644
index 000000000..9475ea752
Binary files /dev/null and b/docs/_static/images/examples_icon_vis_mpl_streamplot.png differ
diff --git a/docs/_static/images/examples_icon_vis_pv_streamlines.png b/docs/_static/images/examples_icon_vis_pv_streamlines.png
new file mode 100644
index 000000000..68595d2b7
Binary files /dev/null and b/docs/_static/images/examples_icon_vis_pv_streamlines.png differ
diff --git a/docs/_static/images/examples_icon_vis_subplots.png b/docs/_static/images/examples_icon_vis_subplots.png
new file mode 100644
index 000000000..d89f896f6
Binary files /dev/null and b/docs/_static/images/examples_icon_vis_subplots.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_LDratio.png b/docs/_static/images/examples_tutorial_magnet_LDratio.png
new file mode 100644
index 000000000..8d0334d15
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_LDratio.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_datasheet.png b/docs/_static/images/examples_tutorial_magnet_datasheet.png
new file mode 100644
index 000000000..1fcc18d8d
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_datasheet.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_datasheet2.png b/docs/_static/images/examples_tutorial_magnet_datasheet2.png
new file mode 100644
index 000000000..8dfa38689
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_datasheet2.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_fieldcomparison.png b/docs/_static/images/examples_tutorial_magnet_fieldcomparison.png
new file mode 100644
index 000000000..63b8cb18b
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_fieldcomparison.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_hysteresis.png b/docs/_static/images/examples_tutorial_magnet_hysteresis.png
new file mode 100644
index 000000000..2378539e2
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_hysteresis.png differ
diff --git a/docs/_static/images/examples_tutorial_magnet_table.png b/docs/_static/images/examples_tutorial_magnet_table.png
new file mode 100644
index 000000000..4e51886ec
Binary files /dev/null and b/docs/_static/images/examples_tutorial_magnet_table.png differ
diff --git a/docs/_static/images/examples_vis_magnet_colors.png b/docs/_static/images/examples_vis_magnet_colors.png
new file mode 100644
index 000000000..92167a599
Binary files /dev/null and b/docs/_static/images/examples_vis_magnet_colors.png differ
diff --git a/docs/_static/images/favicons/android-chrome-192x192.png b/docs/_static/images/favicons/android-chrome-192x192.png
new file mode 100644
index 000000000..547b44146
Binary files /dev/null and b/docs/_static/images/favicons/android-chrome-192x192.png differ
diff --git a/docs/_static/images/favicons/android-chrome-512x512.png b/docs/_static/images/favicons/android-chrome-512x512.png
new file mode 100644
index 000000000..b8bb7f4de
Binary files /dev/null and b/docs/_static/images/favicons/android-chrome-512x512.png differ
diff --git a/docs/_static/images/favicons/apple-touch-icon.png b/docs/_static/images/favicons/apple-touch-icon.png
new file mode 100644
index 000000000..139613fbb
Binary files /dev/null and b/docs/_static/images/favicons/apple-touch-icon.png differ
diff --git a/docs/_static/images/favicons/favicon-16x16.png b/docs/_static/images/favicons/favicon-16x16.png
new file mode 100644
index 000000000..a4ceabadb
Binary files /dev/null and b/docs/_static/images/favicons/favicon-16x16.png differ
diff --git a/docs/_static/images/favicons/favicon-32x32.png b/docs/_static/images/favicons/favicon-32x32.png
new file mode 100644
index 000000000..5a7042328
Binary files /dev/null and b/docs/_static/images/favicons/favicon-32x32.png differ
diff --git a/docs/_static/images/favicons/favicon.ico b/docs/_static/images/favicons/favicon.ico
new file mode 100644
index 000000000..2ed0c433e
Binary files /dev/null and b/docs/_static/images/favicons/favicon.ico differ
diff --git a/docs/_static/images/getting_started_animation.png b/docs/_static/images/getting_started_animation.png
new file mode 100644
index 000000000..2c307f4ac
Binary files /dev/null and b/docs/_static/images/getting_started_animation.png differ
diff --git a/docs/_static/images/getting_started_complex_shapes.png b/docs/_static/images/getting_started_complex_shapes.png
new file mode 100644
index 000000000..f4e2813ee
Binary files /dev/null and b/docs/_static/images/getting_started_complex_shapes.png differ
diff --git a/docs/_static/images/getting_started_fundamentals1.png b/docs/_static/images/getting_started_fundamentals1.png
new file mode 100644
index 000000000..0902e7a6e
Binary files /dev/null and b/docs/_static/images/getting_started_fundamentals1.png differ
diff --git a/docs/_static/images/getting_started_styles.png b/docs/_static/images/getting_started_styles.png
new file mode 100644
index 000000000..1f491bcb8
Binary files /dev/null and b/docs/_static/images/getting_started_styles.png differ
diff --git a/docs/_static/images/index/sal.svg b/docs/_static/images/index/sal.svg
deleted file mode 100644
index 7278ec9b2..000000000
--- a/docs/_static/images/index/sal.svg
+++ /dev/null
@@ -1,39 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- Generator: Adobe Illustrator 15.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="304px" height="66px" viewBox="0 0 304 66" enable-background="new 0 0 304 66" xml:space="preserve">
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polyline fill="#009A9B" points="1,10.431 37.803,5.254 37.803,1 1,6.176 "/>
-<polygon fill="#6F6F6E" points="39.221,5.254 39.221,1 69.074,5.184 69.074,9.438 "/>
-<polygon fill="#6F6F6E" points="42.943,9.332 69.074,12.098 69.074,16.352 42.943,13.586 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polyline fill="#009A9B" points="1,17.841 41.525,13.586 41.525,9.332 1,13.586 "/>
-<polygon fill="#6F6F6E" points="46.631,17.806 69.074,19.366 69.074,23.621 46.631,22.061 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polyline fill="#009A9B" points="1,25.145 45.213,22.061 45.213,17.806 1,20.891 "/>
-<polygon fill="#6F6F6E" points="46.702,26.634 69.074,27.414 69.074,31.669 46.702,30.889 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polyline fill="#009A9B" points="1,32.414 45.284,30.889 45.284,26.634 1,28.159 "/>
-<rect x="43.192" y="35.463" fill="#6F6F6E" width="25.882" height="4.254"/>
-<polyline fill="#009A9B" points="1,35.463 41.773,35.463 41.773,39.717 1,39.717 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polygon fill="#6F6F6E" points="39.611,48.297 69.074,47.27 69.074,43.015 39.611,44.043 "/>
-<polyline fill="#009A9B" points="1,42.73 38.192,44.043 38.192,48.297 1,46.985 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polygon fill="#6F6F6E" points="32.378,64.819 69.074,60.955 69.074,56.7 32.378,60.564 "/>
-<polyline fill="#009A9B" points="1,57.409 30.959,60.564 30.959,64.819 1,61.664 "/>
-<polyline fill="#009A9B" points="1,49.999 34.576,52.375 34.576,56.629 1,54.254 "/>
-<polyline fill-rule="evenodd" clip-rule="evenodd" fill="none" points="1,1 302.299,1 302.299,64.82 1,64.82 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<polygon fill="#6F6F6E" points="35.994,56.629 69.074,54.289 69.074,50.035 35.994,52.375 "/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<path fill="#009A9B" d="M92.936,29.683c-2.695,0-7.126-0.638-9.679-1.205v7.126c3.014,0.567,6.772,1.028,10.424,1.028  c6.772,0,12.834-2.376,12.834-10.424c0-12.622-14.714-7.162-14.714-11.842c0-2.057,1.879-2.659,4.574-2.659  c2.73,0,6.134,0.425,8.403,0.815V5.361c-2.694-0.248-5.673-0.638-8.757-0.638c-6.559,0-13.295,1.844-13.295,9.857  c0,12.834,14.572,7.587,14.572,12.232C97.296,28.797,95.843,29.683,92.936,29.683 M106.196,36.137h9.396l1.489-5.532h9.856  l1.489,5.532h9.396l-9.856-30.917h-11.984L106.196,36.137z M124.987,23.585h-6.063l2.836-11.31h0.39 M139.63,5.254v30.882h21.734  v-7.056h-12.729V5.254H139.63z"/>
-<polyline fill="none" points="1,1 303,1 303,65 1,65 "/>
-<path fill="#009A9B" d="M87.298,59.75c-1.277,0-2.375-0.143-4.042-0.426v1.524c1.17,0.177,2.588,0.354,4.148,0.354  c3.51,0,5.46-1.702,5.46-4.645c0-3.404-2.447-3.794-4.68-4.148c-1.773-0.283-3.404-0.603-3.404-2.695  c0-2.092,1.524-2.693,3.829-2.693c1.241,0,2.588,0.177,3.51,0.283v-1.489c-1.028-0.106-2.269-0.248-3.51-0.248  c-2.801,0-5.531,0.745-5.531,4.147c0,3.156,2.198,3.759,4.325,4.078c1.915,0.318,3.758,0.603,3.758,2.694  C91.198,58.721,89.851,59.75,87.298,59.75 M95.24,60.92h1.631V45.815H95.24V60.92z M100.239,45.815V60.92h8.97v-1.454h-7.375v-13.65  H100.239z M111.301,60.92h1.631V45.815h-1.631V60.92z M115.698,53.367c0,4.787,2.092,7.836,6.772,7.836  c1.063,0,2.517-0.178,3.829-0.567v-1.524c-1.206,0.426-2.588,0.639-3.758,0.639c-2.872,0-5.176-1.703-5.176-6.418  c0-4.609,2.127-6.275,5.318-6.275c1.099,0,2.481,0.248,3.616,0.566V46.1c-1.418-0.32-2.694-0.497-3.829-0.497  C117.86,45.567,115.698,48.616,115.698,53.367 M139.24,53.367c0,4.539-1.879,6.383-4.893,6.383s-4.893-1.88-4.893-6.383  c0-4.502,1.879-6.346,4.893-6.346S139.24,48.865,139.24,53.367 M127.752,53.367c0,4.822,1.986,7.836,6.595,7.836  c4.645,0,6.524-3.014,6.524-7.836c0-4.786-1.879-7.765-6.524-7.765C129.738,45.567,127.752,48.545,127.752,53.367 M153.529,60.92  h1.808V45.815h-1.631v12.622l-8.403-12.622h-1.772V60.92h1.631v-12.48L153.529,60.92z M163.385,60.92h1.738l1.24-3.546h7.127  l1.24,3.546h1.773l-5.39-15.104h-2.376L163.385,60.92z M169.768,47.234h0.318l3.014,8.686h-6.346L169.768,47.234z M189.303,56.133  V45.815h-1.631v10.424c0,2.481-2.127,3.475-4.077,3.475c-1.915,0-4.077-0.993-4.077-3.475V45.815h-1.596v10.282  c0,3.403,2.375,5.07,5.673,5.07C186.856,61.168,189.303,59.572,189.303,56.133 M195.934,59.75c-1.277,0-2.376-0.143-4.043-0.426  v1.524c1.17,0.177,2.589,0.354,4.113,0.354c3.51,0,5.46-1.702,5.46-4.645c0-3.404-2.446-3.794-4.68-4.148  c-1.772-0.283-3.403-0.603-3.403-2.695c0-2.092,1.523-2.693,3.828-2.693c1.241,0,2.589,0.177,3.511,0.283v-1.489  c-1.028-0.106-2.27-0.248-3.511-0.248c-2.801,0-5.531,0.745-5.531,4.147c0,3.156,2.199,3.759,4.326,4.078  c1.914,0.318,3.758,0.603,3.758,2.694C199.833,58.721,198.486,59.75,195.934,59.75 M215.043,45.815h-12.338v1.454h5.354v13.65h1.631  V47.27h5.354V45.815z M217.135,45.815V60.92h1.631v-5.496h2.802c0.673,0,1.17-0.07,1.276-0.07l2.871,5.566h1.809l-3.119-6.027  c1.771-0.958,2.375-2.802,2.375-4.256c0-2.906-1.773-4.785-5.105-4.785h-4.539V45.815z M218.73,47.27h3.049  c2.341,0,3.298,1.666,3.298,3.297c0,1.737-0.957,3.404-3.155,3.404h-3.191V47.27L218.73,47.27z M229.793,60.92h1.631V45.815h-1.631  V60.92z M233.657,60.92h1.737l1.241-3.546h7.126l1.241,3.546h1.772l-5.389-15.104h-2.375L233.657,60.92z M240.004,47.234h0.319  l3.014,8.686h-6.347L240.004,47.234z M254.754,45.815V60.92h8.97v-1.454h-7.375v-13.65H254.754z M265.178,60.92h1.736l1.241-3.546  h7.126l1.242,3.546h1.771l-5.389-15.104h-2.375L265.178,60.92z M271.523,47.234h0.319l3.014,8.686h-6.347L271.523,47.234z   M285.741,45.815h-5.495V60.92h5.814c2.836,0,4.609-1.738,4.609-4.148c0-1.738-0.993-3.121-2.943-3.582  c1.666-0.814,2.34-2.162,2.34-3.651C290.102,47.623,288.684,45.815,285.741,45.815 M285.989,59.501h-4.147v-5.495h3.936  c1.949,0,3.19,1.063,3.19,2.658C288.968,58.26,287.975,59.501,285.989,59.501 M281.842,52.588V47.27h3.687  c1.985,0,2.979,1.135,2.979,2.481c0,1.596-1.241,2.837-2.837,2.837H281.842z M296.732,59.75c-1.276,0-2.375-0.143-4.042-0.426v1.524  c1.17,0.177,2.589,0.354,4.148,0.354c3.51,0,5.46-1.702,5.46-4.645c0-3.404-2.446-3.794-4.68-4.148  c-1.773-0.283-3.404-0.603-3.404-2.695c0-2.092,1.525-2.693,3.829-2.693c1.241,0,2.589,0.177,3.511,0.283v-1.489  c-1.029-0.106-2.27-0.248-3.511-0.248c-2.801,0-5.53,0.745-5.53,4.147c0,3.156,2.197,3.759,4.325,4.078  c1.915,0.318,3.759,0.603,3.759,2.694C300.598,58.721,299.25,59.75,296.732,59.75"/>
-</svg>
\ No newline at end of file
diff --git a/docs/_static/images/index/sourceFundamentals.png b/docs/_static/images/index/sourceFundamentals.png
deleted file mode 100644
index 6659d83d9..000000000
Binary files a/docs/_static/images/index/sourceFundamentals.png and /dev/null differ
diff --git a/docs/_static/images/index_flowchart.png b/docs/_static/images/index_flowchart.png
new file mode 100644
index 000000000..99994606d
Binary files /dev/null and b/docs/_static/images/index_flowchart.png differ
diff --git a/docs/_static/images/index_head.png b/docs/_static/images/index_head.png
new file mode 100644
index 000000000..7d2d356df
Binary files /dev/null and b/docs/_static/images/index_head.png differ
diff --git a/docs/_static/images/index_icon_academic.png b/docs/_static/images/index_icon_academic.png
new file mode 100644
index 000000000..b8795fb3b
Binary files /dev/null and b/docs/_static/images/index_icon_academic.png differ
diff --git a/docs/_static/images/index_icon_examples.png b/docs/_static/images/index_icon_examples.png
new file mode 100644
index 000000000..6fe95e556
Binary files /dev/null and b/docs/_static/images/index_icon_examples.png differ
diff --git a/docs/_static/images/index_icon_get_started.png b/docs/_static/images/index_icon_get_started.png
new file mode 100644
index 000000000..0dc85f8dc
Binary files /dev/null and b/docs/_static/images/index_icon_get_started.png differ
diff --git a/docs/_static/images/install_guide/anaconda0.png b/docs/_static/images/install_guide/anaconda0.png
deleted file mode 100644
index 4f4e060c2..000000000
Binary files a/docs/_static/images/install_guide/anaconda0.png and /dev/null differ
diff --git a/docs/_static/images/install_guide/anaconda1.png b/docs/_static/images/install_guide/anaconda1.png
deleted file mode 100644
index 4eb65566d..000000000
Binary files a/docs/_static/images/install_guide/anaconda1.png and /dev/null differ
diff --git a/docs/_static/images/install_guide/anaconda2.png b/docs/_static/images/install_guide/anaconda2.png
deleted file mode 100644
index b4e18bac8..000000000
Binary files a/docs/_static/images/install_guide/anaconda2.png and /dev/null differ
diff --git a/docs/_static/images/magpylib_flag.png b/docs/_static/images/magpylib_flag.png
index 065601c88..a365f8d48 100644
Binary files a/docs/_static/images/magpylib_flag.png and b/docs/_static/images/magpylib_flag.png differ
diff --git a/docs/_static/images/magpylib_logo.png b/docs/_static/images/magpylib_logo.png
index 7a7556516..c7b6eb20e 100644
Binary files a/docs/_static/images/magpylib_logo.png and b/docs/_static/images/magpylib_logo.png differ
diff --git a/docs/_static/images/matlab_guide/install1.png b/docs/_static/images/matlab_guide/install1.png
deleted file mode 100644
index eccd80d69..000000000
Binary files a/docs/_static/images/matlab_guide/install1.png and /dev/null differ
diff --git a/docs/_static/images/matlab_guide/install2.png b/docs/_static/images/matlab_guide/install2.png
deleted file mode 100644
index 44d43323b..000000000
Binary files a/docs/_static/images/matlab_guide/install2.png and /dev/null differ
diff --git a/docs/_static/images/matlab_guide/install3.png b/docs/_static/images/matlab_guide/install3.png
deleted file mode 100644
index 3e51cd671..000000000
Binary files a/docs/_static/images/matlab_guide/install3.png and /dev/null differ
diff --git a/docs/_static/images/matlab_guide/install4.png b/docs/_static/images/matlab_guide/install4.png
deleted file mode 100644
index d62360f89..000000000
Binary files a/docs/_static/images/matlab_guide/install4.png and /dev/null differ
diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json
new file mode 100644
index 000000000..d042c09c1
--- /dev/null
+++ b/docs/_static/switcher.json
@@ -0,0 +1,24 @@
+[
+    {
+        "version": "dev",
+        "url": "https://magpylib.readthedocs.io/en/latest/"
+    },
+    {
+        "name": "5.1.1 (stable)",
+        "version": "5.1.1",
+        "url": "https://magpylib.readthedocs.io/en/stable",
+        "preferred": true
+    },
+    {
+        "version": "4.5.1",
+        "url": "https://magpylib.readthedocs.io/en/4.5.1/"
+    },
+    {
+        "version": "3.0.5",
+        "url": "https://magpylib.readthedocs.io/en/3.0.5/"
+    },
+    {
+        "version": "2.3.0-beta",
+        "url": "https://magpylib.readthedocs.io/en/2.3.0-beta/"
+    }
+]
diff --git a/docs/_static/videos/axis.mp4 b/docs/_static/videos/axis.mp4
deleted file mode 100644
index 7646fca16..000000000
Binary files a/docs/_static/videos/axis.mp4 and /dev/null differ
diff --git a/docs/_static/videos/example_force_floating_ringdown.gif b/docs/_static/videos/example_force_floating_ringdown.gif
new file mode 100644
index 000000000..d22c4ff59
Binary files /dev/null and b/docs/_static/videos/example_force_floating_ringdown.gif differ
diff --git a/docs/_static/videos/example_gif1.gif b/docs/_static/videos/example_gif1.gif
new file mode 100644
index 000000000..602b74469
Binary files /dev/null and b/docs/_static/videos/example_gif1.gif differ
diff --git a/docs/_static/videos/example_gif2.gif b/docs/_static/videos/example_gif2.gif
new file mode 100644
index 000000000..fdfaa71e0
Binary files /dev/null and b/docs/_static/videos/example_gif2.gif differ
diff --git a/docs/_static/videos/example_gif3.gif b/docs/_static/videos/example_gif3.gif
new file mode 100644
index 000000000..a1c0c0651
Binary files /dev/null and b/docs/_static/videos/example_gif3.gif differ
diff --git a/docs/_static/webcode/summaryOpen.js b/docs/_static/webcode/summaryOpen.js
index 5fe9aa024..1139cee9f 100644
--- a/docs/_static/webcode/summaryOpen.js
+++ b/docs/_static/webcode/summaryOpen.js
@@ -10,7 +10,7 @@ $(document).ready(function() {
       }, 500, 'swing', function() {
         window.location.hash = target;
       });
-  
+
     });
   });
 //https://stackoverflow.com/a/48258026/11028959
diff --git a/docs/conf.py b/docs/conf.py
index fb2e7376f..808535e81 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,122 +1,109 @@
-# -*- coding: utf-8 -*-
-#
-# Configuration file for the Sphinx documentation builder.
-#
-# This file does only contain a selection of the most common options. For a
-# full list see the documentation:
-# http://www.sphinx-doc.org/en/master/config
-
-# -- Path setup --------------------------------------------------------------
-
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#
-# import os
-# import sys
-# sys.path.insert(0, os.path.abspath('.'))
+from __future__ import annotations
 
+import importlib.metadata
 import os
 import sys
-#Location of Sphinx files
-sys.path.insert(0, os.path.abspath('./../')) ##Add the folder one level above 
-os.environ["SPHINX_APIDOC_OPTIONS"] = "members,show-inheritance" ## Hide undocumented members
-import sphinx.apidoc
+from pathlib import Path
+
+import sphinx.ext.apidoc
+
+# This is for pyvista
+os.system("/usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &")
+os.environ["DISPLAY"] = ":99"
+os.environ["PYVISTA_OFF_SCREEN"] = "true"
+os.environ["PYVISTA_USE_IPYVTK"] = "true"
+os.environ["MAGPYLIB_MPL_SVG"] = "true"
+
+# Location of Sphinx files
+
+sys.path.insert(0, str(Path("./../").resolve()))  ##Add the folder one level above
+os.environ["SPHINX_APIDOC_OPTIONS"] = (
+    "members,show-inheritance"  ## Hide undocumented members
+)
 
+# from sphinx_gallery.sorting import FileNameSortKey
+
+# pio.renderers.default = "sphinx_gallery"
 
 autodoc_default_options = {
-    'private-members':True,
-    'inherited-members':True,
+    "private-members": False,
+    "inherited-members": True,
 }
 
-# Recommon Mark Configuration
-from recommonmark.transform import AutoStructify
-github_doc_root = 'https://github.com/rtfd/recommonmark/tree/master/doc/' 
 
 def setup(app):
-    app.add_stylesheet('css/stylesheet.css')
-    app.add_config_value('recommonmark_config', {
-            'url_resolver': lambda url: github_doc_root + url,
-            'auto_toc_tree_section': 'Contents',
-            'enable_eval_rst': True
-            }, True)
-    app.add_transform(AutoStructify) # RecommonMark Configuration for Markdown
-    app.add_javascript('webcode/summaryOpen.js')
-    app.add_javascript('webcode/copybutton.js') # Add the button for 
-                                        # hiding ">>>" in examples
-    sphinx.apidoc.main(['-f', #Overwrite existing files
-                        '-T', #Create table of contents
-                        #'-e', #Give modules their own pages
-                        '-E', #user docstring headers
-                        '-M', #Modules first
-                        '-o', #Output the files to:
-                        './_autogen/', #Output Directory
-                        './../magpylib', #Main Module directory
-                        ]
+    app.add_css_file("css/stylesheet.css")
+    app.add_js_file("webcode/summaryOpen.js")
+    sphinx.ext.apidoc.main(
+        [
+            "-f",  # Overwrite existing files
+            "-T",  # Create table of contents
+            "-e",  # Give modules their own pages
+            "-E",  # user docstring headers
+            "-M",  # Modules first
+            "-o",  # Output the files to:
+            "./docs/_autogen/",  # Output Directory
+            "./src/magpylib",  # Main Module directory
+        ]
     )
 
 
-
-
-
 # -- Project information -----------------------------------------------------
 
-project = 'magpylib'
-copyright = '2019, SAL - Silicon Austria Labs'
-author = 'Michael Ortner <magpylib@gmail.com>'
-
-# The short X.Y version
-version = ''
-# The full version, including alpha/beta/rc tags
-release = '2.3.0-beta'
+project = "Magpylib"
+copyright = "2019-2025, Magpylib developers, License: BSD 2-clause, Built with Sphinx Pydata-Theme"
+author = "The Magpylib Project <magpylib@gmail.com>"
 
+version = release = importlib.metadata.version("magpylib")
 
 # -- General configuration ---------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
 #
-needs_sphinx = '1.8.2'
+needs_sphinx = "7.2"
 
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.autodoc',
-    'sphinx.ext.coverage',
-    'sphinx.ext.imgmath',
-    'sphinx.ext.autosummary',
-    'sphinx.ext.ifconfig',
-    'sphinx.ext.viewcode',
-    'sphinx.ext.napoleon',
-    'matplotlib.sphinxext.plot_directive',
+    "sphinx.ext.napoleon",
+    "sphinx.ext.autodoc",
+    "sphinx.ext.coverage",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.ifconfig",
+    "matplotlib.sphinxext.plot_directive",
+    "sphinx_copybutton",
+    "myst_nb",
+    "sphinx_thebe",
+    "sphinx_favicon",
+    "sphinx_design",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
-# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = [".rst", ".md"]
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 # This pattern also affects html_static_path and html_extra_path.
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store','README*']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "README*"]
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = None
+pygments_style = "sphinx"
 
 
 # -- Options for HTML output -------------------------------------------------
@@ -124,18 +111,85 @@ def setup(app):
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'sphinx_rtd_theme'
-html_logo = './_static/images/magpylib_logo.png'
+html_theme = "pydata_sphinx_theme"
+
+html_logo = "./_static/images/magpylib_logo.png"
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
 #
-# html_theme_options = {}
+
+# Define the json_url for our version switcher.
+json_url = "https://magpylib.readthedocs.io/en/latest/_static/switcher.json"
+
+# Define the version we use for matching in the version switcher.
+version_match = os.environ.get("READTHEDOCS_VERSION")
+
+# If READTHEDOCS_VERSION doesn't exist, we're not on RTD
+# If it is an integer, we're in a PR build and the version isn't correct.
+# If it's "latest" → change to "dev" (that's what we want the switcher to call it)
+if not version_match or version_match.isdigit() or version_match == "latest":
+    # For local development, infer the version to match from the package.
+    if "dev" in release or "rc" in release:
+        version_match = "dev"
+        # We want to keep the relative reference if we are in dev mode
+        # but we want the whole url if we are effectively in a released version
+        json_url = "_static/switcher.json"
+    else:
+        version_match = f"{release}"
+elif version_match == "stable":
+    version_match = f"{release}"
+
+
+html_theme_options = {
+    # "announcement": announcement,
+    "logo": {
+        "text": "Magpylib",
+        "image_dark": "./_static/images/magpylib_logo.png",
+    },
+    "header_links_before_dropdown": 4,
+    "show_version_warning_banner": True,
+    "navbar_align": "content",  # [left, content, right] For testing that the navbar items align properly
+    "navbar_center": ["navbar-nav"],
+    "navbar_persistent": ["version-switcher"],
+    "switcher": {
+        "json_url": json_url,
+        "version_match": version_match,
+    },
+    "check_switcher": True,
+    "icon_links": [
+        {
+            "name": "GitHub",
+            "url": "https://github.com/magpylib/magpylib",
+            "icon": "https://img.shields.io/github/stars/magpylib/magpylib?style=social",
+            "type": "url",
+        },
+    ],
+    "navigation_with_keys": False,
+    "footer_start": ["copyright"],
+    "footer_end": [],
+    "use_edit_page_button": True,
+    "navigation_depth": 3,
+    "collapse_navigation": False,
+}
+
+# "show_nav_level": 2,  # Show navigation up to the second level
+# "navigation_depth": 4,  # Adjust the depth as needed
+# "collapse_navigation": True,  # Option to collapse navigation sections
+
+html_context = {
+    # "github_url": "https://github.com", # or your GitHub Enterprise site
+    "github_user": "magpylib",
+    "github_repo": "magpylib",
+    "github_version": "main",
+    "doc_path": "docs/",
+}
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
+html_css_files = ["custom.css"]
 
 # Custom sidebar templates, must be a dictionary that maps document names
 # to template names.
@@ -145,13 +199,14 @@ def setup(app):
 # default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
 # 'searchbox.html']``.
 #
-# html_sidebars = {}
-
+html_sidebars = {
+    "**": ["search-field.html", "sidebar-nav-bs.html", "sidebar-ethical-ads.html"],
+}
 
 # -- Options for HTMLHelp output ---------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'magpylibdoc'
+htmlhelp_basename = "magpylibdoc"
 
 
 # -- Options for LaTeX output ------------------------------------------------
@@ -160,27 +215,23 @@ def setup(app):
     # The paper size ('letterpaper' or 'a4paper').
     #
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     #
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     #
     # 'preamble': '',
-
     # Latex figure (float) alignment
     #
     # 'figure_align': 'htbp',
-     'extraclassoptions': 'openany,oneside' # Remove empty pages from .PDF download
+    "extraclassoptions": "openany,oneside"  # Remove empty pages from .PDF download
 }
 
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'magpylib.tex', 'magpylib Documentation',
-     'Michael Ortner', 'manual'),
+    (master_doc, "magpylib.tex", "magpylib Documentation", author, "manual"),
 ]
 
 
@@ -188,10 +239,7 @@ def setup(app):
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'magpylib', 'magpylib Documentation',
-     [author], 1)
-]
+man_pages = [(master_doc, "magpylib", "magpylib Documentation", [author], 1)]
 
 
 # -- Options for Texinfo output ----------------------------------------------
@@ -200,9 +248,15 @@ def setup(app):
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'magpylib', 'magpylib Documentation',
-     author, 'magpylib', 'One line description of project.',
-     'Miscellaneous'),
+    (
+        master_doc,
+        "magpylib",
+        "magpylib Documentation",
+        author,
+        "magpylib",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
 
 
@@ -221,17 +275,79 @@ def setup(app):
 # epub_uid = ''
 
 # A list of files that should not be packed into the epub file.
-epub_exclude_files = ['search.html']
+epub_exclude_files = ["search.html"]
 
 
 # -- Extension configuration -------------------------------------------------
 
 # -- Markdown enable
 
-from recommonmark.parser import CommonMarkParser
+# source_suffix = [".rst", ".md"]
+# source_parsers = {
+#     '.md': 'recommonmark.parser.CommonMarkParser',
+# }
+
+# html_js_files = [
+#    "https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js"
+# ]
+
+myst_enable_extensions = [
+    "amsmath",
+    "colon_fence",
+    "deflist",
+    "dollarmath",
+    "html_admonition",
+    "html_image",
+    # "linkify",
+    "replacements",
+    "smartquotes",
+    "substitution",
+    "tasklist",
+]
+
+copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
+copybutton_prompt_is_regexp = True
+
+html_js_files = [
+    "https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js",
+    # "https://unpkg.com/thebe@latest/lib/index.js",
+]
+
+suppress_warnings = [
+    "mystnb.unknown_mime_type",
+]
+
+favicons = [
+    "images/favicons/favicon-16x16.png",
+    "images/favicons/favicon-32x32.png",
+    "images/favicons/icon.ico",
+]
 
-source_parsers = {
-    '.md': CommonMarkParser,
-}
 
-source_suffix = ['.rst', '.md']
\ No newline at end of file
+# sphinx gallery settings
+# sphinx_gallery_conf = {
+#     # convert rst to md for ipynb
+#     # "pypandoc": True,
+#     # path to your example scripts
+#     "examples_dirs": "../examples",
+#     # path to where to save gallery generated output
+#     "gallery_dirs": "auto_examples",
+#     # Remove the "Download all examples" button from the top level gallery
+#     "download_all_examples": False,
+#     # # Remove sphinx configuration comments from code blocks
+#     # "remove_config_comments": True,
+#     # # Sort gallery example by file name instead of number of lines (default)
+#     # "within_subsection_order": FileNameSortKey,
+#     # Modules for which function level galleries are created.  In
+#     "doc_module": "pyvista",
+#     "image_scrapers": ("pyvista", "matplotlib"),
+# }
+
+# import pyvista
+# pyvista.BUILDING_GALLERY = True
+
+# html_last_updated_fmt = ""
+# html_show_copyright = False
+# html_show_sphinx = False
+# show_authors = False
+# html_show_sourcelink = False
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 000000000..677fceb72
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,55 @@
+# Magpylib Documentation
+
+Magpylib is an **open-source Python package** for calculating static **magnetic fields** of magnets, currents, and other sources. It uses **analytical expressions**, solutions to macroscopic magnetostatic problems, implemented in **vectorized** form which makes the computation **extremely fast** and leverages the open-source Python ecosystem for spectacular visualizations!
+
+<h2> Resources </h2>
+
+::::{grid} 1 2 3 3
+:margin: 4 4 0 0
+:gutter: 2
+
+:::{grid-item-card}
+:link: getting-started
+:link-type: ref
+:link-alt: link to Getting Started
+:img-top: _static/images/index_icon_get_started.png
+:text-align: center
+**Getting Started**
+:::
+
+:::{grid-item-card}
+:link: examples
+:link-type: ref
+:link-alt: link to Examples
+:img-top: _static/images/index_icon_examples.png
+:text-align: center
+**Examples**
+:::
+
+:::{grid-item-card}
+:link: https://www.sciencedirect.com/science/article/pii/S2352711020300170
+:link-alt: link to Journal
+:img-top: _static/images/index_icon_academic.png
+:text-align: center
+**Scientific Reference**
+:::
+
+::::
+
+<h2> How it works</h2>
+
+![](_static/images/index_flowchart.png)
+
+In Magpylib, **sources** (magnets, currents, ...) and **observers** (sensors, position grids, ...) are created as Python objects with position and orientation attributes. These objects can be **grouped** and **moved** around. The system can be **viewed** graphically through various backends. The **magnetic field** is computed in the observer reference frame. Magpylib collects all inputs, and vectorizes the computation for maximal performance.
+
+
+```{toctree}
+:maxdepth: 2
+:hidden:
+
+_pages/user_guide/guide_index.md
+_pages/API_reference.md
+_pages/contributing/cont_index.md
+_pages/changelog_.md
+
+```
diff --git a/docs/index.rst b/docs/index.rst
deleted file mode 100644
index 1dc91d880..000000000
--- a/docs/index.rst
+++ /dev/null
@@ -1,81 +0,0 @@
-.. magpylib documentation master file, created by
-   sphinx-quickstart on Tue Feb 26 11:58:33 2019.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
-
-What is Magpylib ?
-##################
-
-- Free Python package for calculating magnetic fields of magnets, currents and moments (sources).
-- Provides convenient methods to create, geometrically manipulate, group and visualize assemblies of sources.
-- The magnetic fields are determined from underlying analytical solutions which results in fast computation times and requires little computation power, memory and background knowledge.
-- For high performance computation (e.g. for multivariate parameter space analysis) all functions are also available in vectorized form.
-
-.. image:: _static/images/index/sourceFundamentals.png
-   :align: center
-
-When can you use Magpylib ?
-###########################
-
-The analytical solutions are only valid if there is little or no material response. This means that whenever there is a lot of demagnetization in permanent magnets or soft magnetic materials like magnetic shields or transformer cores, these computations cannot be used. Magpylib is at its best dealing with air-coils and permanent magnet assemblies (Ferrite, NdFeB, SmCo or similar materials).
-
-
-Quickstart
-##########
-
-Install magpylib with pip: ``>> pip install magpylib``.
-
-**Example:**
-
-Run this simple code to calculate the magnetic field of a cylindrical magnet.
-
-.. code-block:: python
-
-    from magpylib.source.magnet import Cylinder
-    s = Cylinder( mag = [0,0,350], dim = [4,5])
-    print(s.getB([4,4,4]))       
-
-    # Output: [ 5.08641867  5.08641867 -0.60532983]
-
-In this example the cylinder axis is parallel to the z-axis. The diameter and height of the magnet are 4 millimeter and 5 millimeter respectively and the magnet position (=geometric center) is in the
-origin. The magnetization / remanence field is homogeneous and points in z-direction with an amplitude of 350 millitesla.  Finally, the magnetic field **B** is calculated in units of millitesla at
-the positition *[4,4,4]* millimeter.
-
-**Example:**
-
-The following code calculates the combined field of two magnets. They are geometrically manipulated, the system geometry is displayed together with the field in the xz-plane.
-
-.. plot:: pyplots/examples/01_SimpleCollection.py
-   :include-source:
-
-
-More examples can be found in the `Examples Section`__.
-
-__ _pages/2_guideExamples/
-
-Technical details can be found in the :ref:`docu` .
-
-
-.. toctree::
-   :glob:
-   :maxdepth: 1
-   :caption: Content:
-
-   _pages/*
-
-.. toctree::
-   :glob:
-   :maxdepth: 1
-   :caption: Library Docstrings:
-
-   _autogen/magpylib
-   _autogen/magpylib.source
-   _autogen/magpylib.math
-
-
-Index
-################
-
-* :ref:`genindex`
-* :ref:`modindex`
-.. * :ref:`search`
diff --git a/docs/make.bat b/docs/make.bat
deleted file mode 100644
index 27f573b87..000000000
--- a/docs/make.bat
+++ /dev/null
@@ -1,35 +0,0 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
-	set SPHINXBUILD=sphinx-build
-)
-set SOURCEDIR=.
-set BUILDDIR=_build
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
-	echo.
-	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
-	echo.installed, then set the SPHINXBUILD environment variable to point
-	echo.to the full path of the 'sphinx-build' executable. Alternatively you
-	echo.may add the Sphinx directory to PATH.
-	echo.
-	echo.If you don't have Sphinx installed, grab it from
-	echo.http://sphinx-doc.org/
-	exit /b 1
-)
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-
-:end
-popd
diff --git a/docs/pyplots/doku/displaySys.py b/docs/pyplots/doku/displaySys.py
deleted file mode 100644
index e568fda9d..000000000
--- a/docs/pyplots/doku/displaySys.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import magpylib as magpy
-
-# create sources
-s1 = magpy.source.magnet.Cylinder( mag = [1,1,0],dim = [4,5], pos = [0,0,5])
-s2 = magpy.source.magnet.Box( mag = [0,0,-1],dim = [1,2,3],pos=[0,0,-5])
-s3 = magpy.source.current.Circular( curr = 1, dim =10)
-
-#create collection
-c = magpy.Collection(s1,s2,s3)
-
-# create sensors
-se1 = magpy.Sensor(pos=[10,0,0])
-se2 = magpy.Sensor(pos=[10,0,0])
-se3 = magpy.Sensor(pos=[10,0,0])
-se2.rotate(70,[0,0,1],anchor=[0,0,0])
-se3.rotate(140,[0,0,1],anchor=[0,0,0])
-
-#display system
-markerPos = [(0,0,0,'origin'),(10,10,10),(-10,-10,-10)]
-magpy.displaySystem(c,sensors=[se1,se2,se3],markers=markerPos)
\ No newline at end of file
diff --git a/docs/pyplots/doku/sensorSource.py b/docs/pyplots/doku/sensorSource.py
deleted file mode 100644
index cf48e1f4a..000000000
--- a/docs/pyplots/doku/sensorSource.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from magpylib import source, Sensor, Collection
-
-
-##### Single source example
-sensorPosition = [5,0,0]
-sensor = Sensor(pos=sensorPosition,
-                angle=90,
-                axis=(0,0,1))
-
-cyl = source.magnet.Cylinder([1,2,300],[0.2,1.5])
-
-# Read field from absolute position in system
-absoluteReading = cyl.getB(sensorPosition)
-print(absoluteReading)
-# [ 0.50438605   1.0087721  297.3683702 ]
-
-# Now, read from sensor and print the relative output
-relativeReading = sensor.getB(cyl)
-print(relativeReading)
-# [ 1.0087721   -0.50438605 297.3683702 ]
-
diff --git a/docs/pyplots/examples/00a_Trans.py b/docs/pyplots/examples/00a_Trans.py
deleted file mode 100644
index f59e06ff5..000000000
--- a/docs/pyplots/examples/00a_Trans.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import numpy as np
-import magpylib as magpy
-from magpylib.source.magnet import Box
-
-# fixed magnet parameters
-M = [0,0,1] #magnetization
-D = [2,2,2] #dimension
-
-# Translation of magnets can be realized in several ways
-s1 = Box(mag=M, dim=D, pos = [-4,0, 4])
-
-s2 = Box(mag=M, dim=D, pos = [-2,0, 4])
-s2.move([0,0,-2])
-
-s3 = Box(mag=M, dim=D, pos = [ 0,0, 4])
-s3.move([0,0,-2])
-s3.move([0,0,-2])
-
-s4 = Box(mag=M, dim=D, pos = [ 2,0, 4])
-s4.setPosition([2,0,-2])
-
-s5 = Box(mag=M, dim=D, pos = [ 4,0, 4])
-s5.position = np.array([4,0,0])
-
-#collection
-c = magpy.Collection(s1,s2,s3,s4,s5)
-
-#display collection
-magpy.displaySystem(c,figsize=(6,6))
\ No newline at end of file
diff --git a/docs/pyplots/examples/00b_OrientRot1.py b/docs/pyplots/examples/00b_OrientRot1.py
deleted file mode 100644
index caf51563e..000000000
--- a/docs/pyplots/examples/00b_OrientRot1.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from magpylib.source.magnet import Box
-import magpylib as magpy
-
-# fixed magnet parameters
-M = [1,0,0] #magnetization
-D = [4,2,2] #dimension
-
-# magnets with Euler angle orientations
-s1 = Box(mag=M, dim=D, pos = [-4,0, 4])
-s2 = Box(mag=M, dim=D, pos = [ 4,0, 4], angle=45, axis=[0,0,1])
-s3 = Box(mag=M, dim=D, pos = [-4,0,-4], angle=45, axis=[0,1,0])
-s4 = Box(mag=M, dim=D, pos = [ 4,0,-4], angle=45, axis=[1,0,0])
-
-# collection
-c = magpy.Collection(s1,s2,s3,s4)
-
-# display collection
-magpy.displaySystem(c,direc=True,figsize=(6,6))
\ No newline at end of file
diff --git a/docs/pyplots/examples/00c_OrientRot2.py b/docs/pyplots/examples/00c_OrientRot2.py
deleted file mode 100644
index f1a07c5ba..000000000
--- a/docs/pyplots/examples/00c_OrientRot2.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import magpylib as magpy
-from magpylib.source.magnet import Box
-
-# fixed magnet parameters
-M = [0,0,1] #magnetization
-D = [3,3,3] #dimension
-
-# rotation axis
-rax = [-1,1,-1]
-
-# magnets with different orientations
-s1 = Box(mag=M, dim=D, pos=[-6,0,4], angle=0, axis=rax)
-s2 = Box(mag=M, dim=D, pos=[ 0,0,4], angle=45, axis=rax)
-s3 = Box(mag=M, dim=D, pos=[ 6,0,4], angle=90, axis=rax)
-
-# magnets that are rotated differently
-s4 =  Box(mag=M, dim=D, pos=[-6,0,-4])
-s5 =  Box(mag=M, dim=D, pos=[ 0,0,-4])
-s5.rotate(45,rax)
-s6 = Box(mag=M, dim=D, pos=[ 6,0,-4])
-s6.rotate(90,rax)
-
-# collect all sources
-c = magpy.Collection(s1,s2,s3,s4,s5,s6)
-
-# display collection
-magpy.displaySystem(c,figsize=(6,6))
\ No newline at end of file
diff --git a/docs/pyplots/examples/00d_OrientRot3.py b/docs/pyplots/examples/00d_OrientRot3.py
deleted file mode 100644
index 3254d2d1b..000000000
--- a/docs/pyplots/examples/00d_OrientRot3.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import magpylib as magpy
-from magpylib.source.magnet import Box
-import matplotlib.pyplot as plt
-
-# define figure
-fig = plt.figure(figsize=(6,6))
-ax = fig.add_subplot(1,1,1, projection='3d')
-
-# fixed magnet parameters
-M = [0,0,1] #magnetization
-D = [2,4,1] #dimension
-
-# define magnets rotated with different pivot and anchor points
-piv1 = [-7,0,5]
-s1 = Box(mag=M, dim=D, pos = [-7,-3,5])
-
-piv2 = [0,0,5]
-s2 = Box(mag=M, dim=D, pos = [0,-3,5])
-s2.rotate(-30,[0,0,1],anchor=piv2)
-
-piv3 = [7,0,5]
-s3 = Box(mag=M, dim=D, pos = [7,-3,5])
-s3.rotate(-60,[0,0,1],anchor=piv3)
-
-piv4 = [-7,0,-5]
-anch4 = [-7,0,-2]
-s4 = Box(mag=M, dim=D, pos = [-7,-3,-5])
-
-piv5 = [0,0,-5]
-anch5 = [0,0,-2]
-s5 = Box(mag=M, dim=D, pos = [0,-3,-5])
-s5.rotate(-45,[0,0,1],anchor=anch5)
-
-piv6 = [7,0,-5]
-anch6 = [7,0,-8]
-s6 = Box(mag=M, dim=D, pos = [7,-3,-5])
-s6.rotate(-45,[0,0,1],anchor=anch6)
-
-# collect all sources
-c = magpy.Collection(s1,s2,s3,s4,s5,s6)
-
-# draw rotation axes
-for x in [-7,0,7]:
-    for z in [-5,5]:
-        ax.plot([x,x],[0,0],[z-3,z+4],color='.3')
-
-# define markers
-Ms = [piv1+['piv1'], piv2+['piv2'], piv3+['piv3'], piv4+['piv4'],
-      piv5+['piv5'], piv6+['piv6'], anch4+['anch4'],anch5+['anch5'],anch6+['anch6']]
-
-# display system
-magpy.displaySystem(c,subplotAx=ax,markers=Ms,suppress=True)
-
-plt.show()
\ No newline at end of file
diff --git a/docs/pyplots/examples/00e_ColTransRot.py b/docs/pyplots/examples/00e_ColTransRot.py
deleted file mode 100644
index d1827efe8..000000000
--- a/docs/pyplots/examples/00e_ColTransRot.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import magpylib as magpy
-from magpylib.source.current import Circular
-from numpy import linspace
-
-# windings of three parts of a coil
-coil1a = [Circular(curr=1,dim=3,pos=[0,0,z]) for z in linspace( -3,-1,10)]
-coil1b = [Circular(curr=1,dim=3,pos=[0,0,z]) for z in linspace( -1, 1,10)]
-coil1c = [Circular(curr=1,dim=3,pos=[0,0,z]) for z in linspace(  1, 3,10)]
-
-# create collection and manipulate step by step
-c1 = magpy.Collection(coil1a)
-c1.move([-1,-1,0])
-c1.addSources(coil1b)
-c1.move([-1,-1,0])
-c1.addSources(coil1c)
-c1.move([-1,-1,0])
-
-# windings of three parts of another coil
-coil2a = [Circular(curr=1,dim=3,pos=[3,3,z]) for z in linspace(-3,-1,15)]
-coil2b = [Circular(curr=1,dim=3,pos=[3,3,z]) for z in linspace( -1,1,15)]
-coil2c = [Circular(curr=1,dim=3,pos=[3,3,z]) for z in linspace( 1,3,15)]
-
-# create individual sub-collections
-c2a = magpy.Collection(coil2a)
-c2b = magpy.Collection(coil2b)
-c2c = magpy.Collection(coil2c)
-
-# combine sub-collections to one big collection
-c2 = magpy.Collection(c2a,c2b,c2c)
-
-# still manipulate each individual sub-collection
-c2a.rotate(-15,[1,-1,0],anchor=[0,0,0])
-c2c.rotate(15,[1,-1,0],anchor=[0,0,0])
-
-# combine all collections and display system
-c3 = magpy.Collection(c1,c2)
-magpy.displaySystem(c3,figsize=(6,6))
\ No newline at end of file
diff --git a/docs/pyplots/examples/01_SimpleCollection.py b/docs/pyplots/examples/01_SimpleCollection.py
deleted file mode 100644
index 5f85bda02..000000000
--- a/docs/pyplots/examples/01_SimpleCollection.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import numpy as np
-import matplotlib.pyplot as plt
-from magpylib.source.magnet import Box,Cylinder
-from magpylib import Collection, displaySystem
-
-# create magnets
-s1 = Box(mag=(0,0,600), dim=(3,3,3), pos=(-4,0,3))
-s2 = Cylinder(mag=(0,0,500), dim=(3,5))
-
-# create collection
-c = Collection(s1,s2)
-
-# manipulate magnets individually
-s1.rotate(45,(0,1,0), anchor=(0,0,0))
-s2.move((5,0,-4))
-
-# manipulate collection
-c.move((-2,0,0))
-
-# calculate B-field on a grid
-xs = np.linspace(-10,10,33)
-zs = np.linspace(-10,10,44)
-POS = np.array([(x,0,z) for z in zs for x in xs])
-Bs = c.getB(POS).reshape(44,33,3)     #<--VECTORIZED
-
-# create figure
-fig = plt.figure(figsize=(9,5))
-ax1 = fig.add_subplot(121, projection='3d')  # 3D-axis
-ax2 = fig.add_subplot(122)                   # 2D-axis
-
-# display system geometry on ax1
-displaySystem(c, subplotAx=ax1, suppress=True)
-
-# display field in xz-plane using matplotlib
-X,Z = np.meshgrid(xs,zs)
-U,V = Bs[:,:,0], Bs[:,:,2]
-ax2.streamplot(X, Z, U, V, color=np.log(U**2+V**2))
-
-plt.show()
\ No newline at end of file
diff --git a/docs/pyplots/examples/01b_AllSources.py b/docs/pyplots/examples/01b_AllSources.py
deleted file mode 100644
index d3849624f..000000000
--- a/docs/pyplots/examples/01b_AllSources.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import magpylib as magpy
-import numpy as np
-from matplotlib import pyplot as plt
-
-# set font size and define figures
-plt.rcParams.update({'font.size': 6})
-
-fig1 = plt.figure(figsize=(8, 5))
-axsA = [fig1.add_subplot(2,3,i, projection='3d') for i in range(1,4)]
-axsB = [fig1.add_subplot(2,3,i) for i in range(4,7)]
-
-fig2 = plt.figure(figsize=(8, 5))
-axsA += [fig2.add_subplot(2,3,i, projection='3d') for i in range(1,4)]
-axsB += [fig2.add_subplot(2,3,i) for i in range(4,7)]
-
-# position grid
-ts = np.linspace(-6,6,50)
-posis = np.array([(x,0,z) for z in ts for x in ts])
-X,Y = np.meshgrid(ts,ts)
-
-# create the source objects
-s1 = magpy.source.magnet.Box(mag=[500,0,500], dim=[4,4,4])                #Box
-s2 = magpy.source.magnet.Cylinder(mag=[0,0,500], dim=[3,5])               #Cylinder
-s3 = magpy.source.magnet.Sphere(mag=[-200,0,500], dim=5)                  #Sphere
-s4 = magpy.source.current.Line(curr=10, vertices=[(0,-5,0),(0,5,0)])      #Line
-s5 = magpy.source.current.Circular(curr=10, dim=5)                        #Circular
-s6 = magpy.source.moment.Dipole(moment=[0,0,100])                         #Dipole
-
-for i,s in enumerate([s1,s2,s3,s4,s5,s6]):
-
-    # display system on respective axes, use marker to zoom out
-    magpy.displaySystem(s,subplotAx=axsA[i],markers=[(6,0,6)],suppress=True)
-    axsA[i].plot([-6,6,6,-6,-6],[0,0,0,0,0],[-6,-6,6,6,-6])
-
-    # plot field on respective axes
-    B = np.array([s.getB(p) for p in posis]).reshape(50,50,3)
-    axsB[i].pcolor(X,Y,np.linalg.norm(B,axis=2),cmap=plt.cm.get_cmap('coolwarm'))   # amplitude
-    axsB[i].streamplot(X, Y, B[:,:,0], B[:,:,2], color='k',linewidth=1)             # field lines
-
-plt.show()
\ No newline at end of file
diff --git a/docs/pyplots/examples/02_MagnetMotion.py b/docs/pyplots/examples/02_MagnetMotion.py
deleted file mode 100644
index 9c1a86370..000000000
--- a/docs/pyplots/examples/02_MagnetMotion.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from magpylib.source.magnet import Cylinder
-import matplotlib.pyplot as plt
-import numpy as np
-
-# system parameters
-D,H = 5,4       #magnet dimension
-M0 = 1200       #magnet magnetization amplitude
-gap = 3         #airgap
-d = 5           #distance magnet to center of tilt
-thMAX = 15      #maximal joystick tilt angle
-
-# define figure
-fig = plt.figure(figsize=(7,6))
-ax = plt.axes(projection='3d')
-cm = plt.get_cmap("jet") #colormap
-
-# set tilt angle
-for th in np.linspace(1,thMAX,30):
-    
-    # store fields here
-    Bs = np.zeros([181,3])
-
-    # create magnet for joystick in center position
-    s = Cylinder(dim=[D,H],mag=[0,0,M0],pos=[0,0,H/2+gap])
-    
-    # set joystick tilt th
-    s.rotate(th,[0,1,0],anchor=[0,0,gap+H+d])
-    
-    # rotate joystick for fixed tilt
-    for i in range(181):
-        
-        # calculate field (sensor at [0,0,0]) and store in Bs
-        Bs[i] = s.getB([0,0,0])
-        
-        # rotate magnet to next position
-        s.rotate(2,[0,0,1],anchor=[0,0,0])
-
-    # plot fields
-    ax.plot(Bs[:,0],Bs[:,1],Bs[:,2],color=cm(th/15))
-
-# annotate
-ax.set(
-       xlabel = 'Bx [mT]',
-       ylabel = 'By [mT]',
-       zlabel = 'Bz [mT]')
-
-# display
-plt.show()
\ No newline at end of file
diff --git a/docs/pyplots/examples/04_ComplexShape.py b/docs/pyplots/examples/04_ComplexShape.py
deleted file mode 100644
index f744bbd49..000000000
--- a/docs/pyplots/examples/04_ComplexShape.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import numpy as np
-import matplotlib.pyplot as plt
-from magpylib.source.magnet import Cylinder
-import magpylib as magpy
-
-# create collection of two magnets
-s1 = Cylinder(mag=[0,0,1000], dim=[5,5])
-s2 = Cylinder(mag=[0,0,-1000], dim=[2,6])
-c = magpy.Collection(s1,s2)
-
-# create positions
-xs = np.linspace(-8,8,100)
-zs = np.linspace(-6,6,100)
-posis = [[x,0,z] for z in zs for x in xs]
-
-# calculate field and amplitude
-B = [c.getB(pos) for pos in posis]
-Bs = np.array(B).reshape([100,100,3]) #reshape
-Bamp = np.linalg.norm(Bs,axis=2)
-
-# define figure with a 2d and a 3d axis
-fig = plt.figure(figsize=(8,4))
-ax1 = fig.add_subplot(121,projection='3d')
-ax2 = fig.add_subplot(122)
-
-# add displaySystem on ax1
-magpy.displaySystem(c,subplotAx=ax1,suppress=True)
-ax1.view_init(elev=75)
-
-# amplitude plot on ax2
-X,Z = np.meshgrid(xs,zs)
-ax2.pcolor(xs,zs,Bamp,cmap='jet',vmin=-200)
-
-# plot field lines on ax2
-U,V = Bs[:,:,0], Bs[:,:,2]
-ax2.streamplot(X,Z,U,V,color='k',density=2)
-
-#display
-plt.show()
\ No newline at end of file
diff --git a/docs/pyplots/examples/05_VectorJoystick1d.py b/docs/pyplots/examples/05_VectorJoystick1d.py
deleted file mode 100644
index 6d92ed6c3..000000000
--- a/docs/pyplots/examples/05_VectorJoystick1d.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import magpylib as magpy
-import numpy as np
-import matplotlib.pyplot as plt
-
-import time
-
-# vector size: we calculate the field N times with different inputs
-N = 100000  
-
-# Constant vectors
-mag  = np.array([0,0,1000])    # magnet magnetization
-dim  = np.array([2,2,2])       # magnet dimension
-poso = np.array([0,0,0])       # position of observer
-posm = np.array([0,0,3])       # initial magnet position
-anch = np.array([0,0,8])       # rotation anchor
-axis = np.array([1,0,0])       # rotation axis
-
-# different angles for each evaluation
-angs = np.linspace(-20,20,N) 
-
-# Vectorizing input using numpy native instead of python loops
-MAG = np.tile(mag,(N,1))        
-DIM = np.tile(dim,(N,1))        
-POSo = np.tile(poso,(N,1))
-POSm = np.tile(posm,(N,1))  # inital magnet positions before rotations are applied
-ANCH = np.tile(anch,(N,1))  # always same axis
-AXIS = np.tile(axis,(N,1))  # always same anchor
-
-# N-times evalulation of the field with different inputs
-Bv = magpy.vector.getBv_magnet('box',MAG,DIM,POSo,POSm,[angs],[AXIS],[ANCH])
-
-# plot field
-plt.plot(angs,Bv[:,0])
-plt.plot(angs,Bv[:,1])
-plt.plot(angs,Bv[:,2])
-
-plt.show()
diff --git a/docs/requirements.txt b/docs/requirements.txt
deleted file mode 100644
index f048a63a4..000000000
--- a/docs/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-recommonmark>=0.5
-sphinx_rtd_theme>=0.4
-sphinx>=1.8.2
\ No newline at end of file
diff --git a/magpylib/__init__.py b/magpylib/__init__.py
deleted file mode 100644
index 281fb072d..000000000
--- a/magpylib/__init__.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-This is the top level of the package. From here you can call subpackages 
-`source` and `math`, the classes `Collection` and `Sensor` as well as the
-functions `getBv` and `displaySystem`.
-"""
-
-__all__ = ["source", "Collection", "Sensor", "vector", "math", "displaySystem"]  # This is for Sphinx
-
-from ._lib.classes.collection import Collection
-from ._lib.classes.sensor import Sensor
-from ._lib.displaySystem import displaySystem
-from . import source, math, vector
-
-from . import _lib                                #why is this here ?
diff --git a/magpylib/_lib/__init__.py b/magpylib/_lib/__init__.py
deleted file mode 100644
index 80fa2a853..000000000
--- a/magpylib/_lib/__init__.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-from magpylib._lib import mathLib
-from magpylib._lib import mathLib_vector
-from magpylib._lib import classes
-from magpylib._lib import fields
diff --git a/magpylib/_lib/classes/__init__.py b/magpylib/_lib/classes/__init__.py
deleted file mode 100644
index e5908c536..000000000
--- a/magpylib/_lib/classes/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,  
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>. 
-# The acceptance of the conditions of the GNU Affero General Public License are 
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
\ No newline at end of file
diff --git a/magpylib/_lib/classes/base.py b/magpylib/_lib/classes/base.py
deleted file mode 100644
index c4eaea0d4..000000000
--- a/magpylib/_lib/classes/base.py
+++ /dev/null
@@ -1,358 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-'''
-Base Functions
-==============
-Define base classes here on which the magnetic source objects are built on
-
-    1. RCS class: the underlying relative coordintate system initiates position
-                    and orientation and provides object movement and rotation
-                    functionalities.
-    2. HomoMag class: initializes the homogeneous magnetization for all homogeneous
-                    magnet classes
-    3. LineCurrent class: initializes a current for all line current classes
-    4. MagMoment class: initializes a Moment for all line moment classes
-
-'''
-
-# tool-tip / intellisense helpers -----------------------------------------------
-# Class initialization is done purely by kwargs. While some # of these can be 
-# set to zero by default other MUST be given to make any sense 
-# (e.g. magnetization). To improve tool tips and intellisense we inizilize them
-# with names, e.g. mag=(Mx, My, Mz). This looks good, but it requires that
-# these names are pre-initialzed:
-Auto = 0 # Maximum cores, for multicore
-        # function. if 0 find max.
-numpyArray = 0
-constant = None
-Mx=My=Mz=0.0 # Zero Moment
-
-
-# -------------------------------------------------------------------------------
-from numpy import array, float64, pi, isnan, array
-from magpylib._lib.mathLib import Qmult, Qconj, getRotQuat, arccosSTABLE, fastSum3D, fastNorm3D
-from magpylib._lib.utility import checkDimensions
-import sys
-
-
-# -------------------------------------------------------------------------------
-# FUNDAMENTAL CLASS - RCS (RELATIVE COORDINATE SYSTEM)
-#       - initiates position, orientation
-#       - adds moveBY, rotateBy
-
-class RCS:
-    """
-    base class RCS(RELATIVE COORDINATE SYSTEM)
-
-    initiates position, orientation (angle, axis)
-    adds methods setPosition, move, setOrientation, rotate
-    """
-
-    def __init__(self, position, angle, axis):
-        # fundamental (unit)-orientation/rotation is [0,0,0,1]
-        assert any(
-            ax != 0 for ax in axis), "Invalid Axis input for Sensor (0,0,0)"
-        assert all(
-            isinstance(ax, int) or isinstance(ax, float) for ax in axis), "Invalid Axis input for Sensor" + str(axis)
-
-        self.position = array(position, dtype=float64, copy=False)
-        try:
-            self.angle = float(angle)
-        except ValueError:
-            sys.exit('Bad angle input')
-        self.axis = array(axis, dtype=float64, copy=False)
-
-        # check input format
-        if any(isnan(self.position)) or len(self.position) != 3:
-            sys.exit('Bad pos input')
-        if any(isnan(self.axis)) or len(self.axis) != 3:
-            sys.exit('Bad axis input')
-
-    def setPosition(self, newPos):
-        """
-        This method moves the source to the position given by the argument 
-        vector `newPos`. Vector input format can be either list, tuple or array
-        of any data type (float, int)
-
-        Parameters
-        ----------
-        newPos : vec3 [mm]
-            Set new position of the source.
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source
-        >>> pm = source.magnet.Sphere(mag=[0,0,1000],dim=1)
-        >>> print(pm.position)
-            [0. 0. 0.]
-        >>> pm.setPosition([5,5,5])
-        >>> print(pm.position)
-            [5. 5. 5.]
-        """
-        self.position = array(newPos, dtype=float64, copy=False)
-        if any(isnan(self.position)) or len(self.position) != 3:
-            sys.exit('Bad pos input')
-
-    def move(self, displacement):
-        """
-        This method moves the source by the argument vector `displacement`. 
-        Vector input format can be either list, tuple or array of any data
-        type (float, int).
-
-        Parameters
-        ----------
-        displacement : vec3 [mm]
-            Set displacement vector
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source
-        >>> pm = source.magnet.Sphere(mag=[0,0,1000],dim=1,pos=[1,2,3])
-        >>> print(pm.position)
-            [1. 2. 3.]
-        >>> pm.move([3,2,1])
-        >>> print(pm.position)
-            [4. 4. 4.]
-        """
-        mV = array(displacement, dtype=float64, copy=False)
-        if any(isnan(mV)) or len(mV) != 3:
-            sys.exit('Bad move vector input')
-        self.position = self.position + mV
-
-    def setOrientation(self, angle, axis):
-        """
-        This method sets a new source orientation given by `angle` and `axis`.
-        Scalar input is either integer or float. Vector input format can be
-        either list, tuple or array of any data type (float, int).
-
-        Parameters
-        ----------
-        angle  : scalar [deg]
-            Set new angle of source orientation.
-
-        axis : vec3 []
-            Set new axis of source orientation.
-
-        Returns
-        -------
-        None            
-
-        Example
-        -------
-        >>> from magpylib import source
-        >>> pm = source.magnet.Sphere(mag=[0,0,1000],dim=1)
-        >>> print([pm.angle,pm.axis])
-            [0.0, array([0., 0., 1.])]
-        >>> pm.setOrientation(45,[0,1,0])
-        >>> print([pm.angle,pm.axis])
-            [45.0, array([0., 1., 0.])]
-        """
-        try:
-            self.angle = float(angle)
-        except ValueError:
-            sys.exit('Bad angle input')
-        self.axis = array(axis, dtype=float64, copy=False)
-        if any(isnan(self.axis)) or len(self.axis) != 3:
-            sys.exit('Bad axis input')
-
-    def rotate(self, angle, axis, anchor='self.position'):
-        """
-        This method rotates the source about `axis` by `angle`. The axis passes
-        through the center of rotation anchor. Scalar input is either integer or
-        float. Vector input format can be either list, tuple or array of any
-        data type (float, int).
-
-        Parameters
-        ----------
-        angle  : scalar [deg]
-            Set angle of rotation in units of [deg]
-        axis : vec3 []
-            Set axis of rotation
-        anchor : vec3 [mm]
-            Specify the Center of rotation which defines the position of the
-            axis of rotation. If not specified the source will rotate about its
-            own center.
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source
-        >>> pm = source.magnet.Sphere(mag=[0,0,1000], dim=1)
-        >>> print(pm.position, pm.angle, pm.axis)
-          [0. 0. 0.] 0.0 [0. 0. 1.]
-        >>> pm.rotate(90, [0,1,0], anchor=[1,0,0])
-        >>> print(pm.position, pm.angle, pm.axis)
-          [1., 0., 1.] 90.0 [0., 1., 0.]
-        """
-        # secure type
-        ax = array(axis, dtype=float64, copy=False)
-
-        try:
-            ang = float(angle)
-        except ValueError:
-            sys.exit('Bad angle input')
-
-        if str(anchor) == 'self.position':
-            anchor = self.position
-        else:
-            anchor = array(anchor, dtype=float64, copy=False)
-
-        # check input
-        if any(isnan(ax)) or len(ax) != 3:
-            sys.exit('Bad axis input')
-        if fastSum3D(ax**2) == 0:
-            sys.exit('Bad axis input')
-        if any(isnan(anchor)) or len(anchor) != 3:
-            sys.exit('Bad anchor input')
-
-        # determine Rotation Quaternion Q from self.axis-angle
-        Q = getRotQuat(self.angle, self.axis)
-
-        # determine rotation Quaternion P from rot input
-        P = getRotQuat(ang, ax)
-
-        # determine new orientation quaternion which follows from P.Q v (P.Q)*
-        R = Qmult(P, Q)
-
-        # reconstruct new axis-angle from new orientation quaternion
-        ang3 = arccosSTABLE(R[0])*180/pi*2
-
-        ax3 = R[1:]  # konstanter mult faktor ist wurscht für ax3
-        self.angle = ang3
-        if ang3 == 0:  # avoid returning a [0,0,0] axis
-            self.axis = array([0, 0, 1])
-        else:
-            Lax3 = fastNorm3D(ax3)
-            self.axis = array(ax3)/Lax3
-
-        # set new position using P.v.P*
-        posOld = self.position-anchor
-        Vold = [0] + [p for p in posOld]
-        Vnew = Qmult(P, Qmult(Vold, Qconj(P)))
-        self.position = array(Vnew[1:])+anchor
-
-
-#------------------------------------------------------------------------------
-class FieldSampler:
-    """
-    Field Sampler Class
-    
-    This class initiates the getB method and is inherited by all source objects.
-    The main reason this is centralized here is that the docstring of the
-    getB method is inherited by all sources.
-    """
-
-    def getB(self, pos):
-        """
-        This method returns the magnetic field vector generated by the source
-        at the argument position `pos` in units of [mT]
-
-        Parameters
-        ----------
-        pos : vec3 [mm] Position or list of Positions where magnetic field
-            should be determined.
-
-
-        Returns
-        -------
-        magnetic field vector : arr3 [mT] Magnetic field at the argument
-            position `pos` generated by the source in units of [mT].
-        """
-        # Return a list of vec3 results
-        # This method will be overriden by the classes that inherit it.
-        # Throw a warning and return 0s if it somehow isn't.
-        # Note: Collection() has its own docstring
-        # for getB since it inherits nothing.
-        import warnings
-        warnings.warn(
-            "called getB method is not implemented in this class,"
-            "returning [0,0,0]", RuntimeWarning)
-        return [0, 0, 0]
-
-
-
-#------------------------------------------------------------------------------
-#------------------------------------------------------------------------------
-
-
-
-# subclass for HOMOGENEOUS MAGNETS --------------------------------------
-#       - initiates magnetization
-
-class HomoMag(RCS, FieldSampler):
-
-    def __init__(self, position, angle, axis, magnetization):
-
-        # inherit class RCS
-        RCS.__init__(self, position, angle, axis)
-        assert all(
-            a == 0 for a in magnetization) is False, "Bad mag input, all values are zero"
-
-        # secure input type and check input format of mag
-        self.magnetization = array(magnetization, dtype=float64, copy=False)
-        assert (not any(isnan(self.magnetization)) and len(
-            self.magnetization) == 3), "Bad mag input, invalid vector dimension"
-
-
-# subclass for LINE CURRENTS ---------------------------------------------
-#       - initiates current
-
-class LineCurrent(RCS, FieldSampler):
-
-    def __init__(self, position, angle, axis, current):
-
-        # inherit class RCS
-        RCS.__init__(self, position, angle, axis)
-
-        # secure input types and check input format
-        try:
-            self.current = float(current)
-        except ValueError:
-            sys.exit('Bad current input')
-
-
-# subclass for MOMENTS ----------------------------------------------------
-#       - initiates nothing
-class MagMoment(RCS, FieldSampler):
-
-    def __init__(self, moment=(Mx, My, Mz),
-                 pos=(0.0, 0.0, 0.0),
-                 angle=0.0, axis=(0.0, 0.0, 1.0)):
-
-        # inherit class RCS
-        RCS.__init__(self, pos, angle, axis)
-
-        # secure input type and check input format of moment
-        self.moment = checkDimensions(3, moment, "Bad moment input")
diff --git a/magpylib/_lib/classes/collection.py b/magpylib/_lib/classes/collection.py
deleted file mode 100644
index 97c9a6025..000000000
--- a/magpylib/_lib/classes/collection.py
+++ /dev/null
@@ -1,278 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from magpylib._lib.classes.base import FieldSampler
-from magpylib._lib.utility import addListToCollection, isSource,  addUniqueSource
-
-class Collection(FieldSampler):
-    """
-    Create a collection of :mod:`magpylib.source` objects for common manipulation.
-
-    Parameters
-    ----------
-    sources : source objects
-        python magic variable passes source objects to the collection at initialization.
-
-    Attributes
-    ----------
-    sources : list of source objects
-        List of all sources that have been added to the collection.
-
-    Example
-    -------
-        >>> from magpylib import source, Collection
-        >>> pm1 = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1])
-        >>> pm2 = source.magnet.Cylinder(mag=[0,0,1000],dim=[1,1])
-        >>> pm3 = source.magnet.Sphere(mag=[0,0,1000],dim=1)
-        >>> col = Collection(pm1,pm2,pm3)
-        >>> B = col.getB([1,0,1])
-        >>> print(B)
-        [9.93360625e+01 1.76697482e-14 3.12727683e+01]
-    """
-
-    def __init__(self, *sources, dupWarning=True):
-
-        self.sources = []
-
-        # The following will add Sources to the Collection sources list,
-        # The code is the same as the addsource method.
-        # addSource() is not cast here because it will
-        # put a tuple inside a tuple.
-        # Iterating for this would compromise performance.
-        for s in sources:
-            if type(s) == Collection:
-                addListToCollection(self.sources, s.sources, dupWarning)
-            elif isinstance(s, list) or isinstance(s, tuple):
-                addListToCollection(self.sources, s, dupWarning)
-            else:
-                assert isSource(s), "Argument " + str(s) + \
-                    " in addSource is not a valid source for Collection"
-                if dupWarning is True:
-                    addUniqueSource(s, self.sources)
-                else:
-                    self.sources += [s]
-
-    def removeSource(self, source_ref=-1):
-        """
-        Remove a source from the sources list. 
-
-        Parameters
-        ----------
-
-        source_ref : source object or int
-            [Optional] Remove the inputted source from the list
-            [Optional] If given an int, remove a source at the given index position. Default: Last position.
-
-        Return
-        ------
-
-        Popped source object.
-
-        Raises
-        ------
-
-        ValueError
-            Will be thrown if you attempt to remove a source that is not in the Collection.
-
-        AssertionError
-            Will be thrown if inputted index kwarg type is not type int
-
-        Example
-        -------
-
-            >>> from magpylib import Collection, source
-            >>> s = source.magnet.Sphere(mag=[1,2,3],dim=1,pos=[3,3,3])
-            >>> s2 = source.magnet.Sphere(mag=[1,2,3],dim=2,pos=[-3,-3,-3])
-            >>> m = source.moment.Dipole(moment=[1,2,3],pos=(0,0,0))
-            >>> c = Collection(s,s2,m)
-            >>> print(c.sources)
-            [<magpylib._lib.classes.magnets.Sphere object at 0xa31eafcc>, 
-            <magpylib._lib.classes.magnets.Sphere object at 0xa31ea1cc>, 
-            <magpylib._lib.classes.moments.Dipole object at 0xa31ea06c>]
-            >>> c.removeSource(s)
-            >>> print(c.sources)
-            [<magpylib._lib.classes.magnets.Sphere object at 0xa31ea1cc>, 
-            <magpylib._lib.classes.moments.Dipole object at 0xa31ea06c>]
-            >>> c.removeSource(s2)
-            >>> print(c.sources)
-            [<magpylib._lib.classes.moments.Dipole object at 0xa31ea06c>]
-            >>> c.removeSource()
-            >>> print(c.sources)
-            []
-
-
-
-        """
-        assert type(source_ref) == int or isSource(
-            source_ref), "Reference in removeSource is not an int nor a source"
-        if type(source_ref) == int:
-            try:
-                return self.sources.pop(source_ref)
-            except IndexError as e:  # Give a more helpful error message.
-                raise type(e)(str(e) + ' - Index ' + str(source_ref) +
-                              ' in collection source is not accessible for removeSource')
-        else:
-            try:
-                self.sources.remove(source_ref)
-            except ValueError as e:  # Give a more helpful error message.
-                raise type(e)(str(e) + ' - ' + str(type(source_ref)
-                                                   ) + ' not in list for removeSource')
-            return source_ref
-
-    def addSources(self, *sources, dupWarning=True):
-        """
-        This method adds the argument source objects to the collection.
-        May also include other collections.
-
-        Parameters
-        ----------
-        source : source object
-            adds the source object `source` to the collection.
-
-        dupWarning : bool
-            Warn and prevent if there is an attempt to add a 
-            duplicate source into the collection. Set to false to disable
-            check and increase performance.
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source, Collection
-        >>> pm1 = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1])
-        >>> pm2 = source.magnet.Cylinder(mag=[0,0,1000],dim=[1,1])
-        >>> pm3 = source.magnet.Sphere(mag=[0,0,1000],dim=1)
-        >>> col = Collection(pm1)
-        >>> print(col.getB([1,0,1]))
-          [4.29223532e+01 1.76697482e-14 1.37461635e+01]
-        >>> col.addSource(pm2)
-        >>> print(col.getB([1,0,1]))
-          [7.72389756e+01 1.76697482e-14 2.39070726e+01]
-        >>> col.addSource(pm3)
-        >>> print(
-          [9.93360625e+01 1.76697482e-14 3.12727683e+01]
-        """
-        for s in sources:
-            if type(s) == Collection:
-                addListToCollection(self.sources, s.sources, dupWarning)
-            elif isinstance(s, list) or isinstance(s, tuple):
-                addListToCollection(self.sources, s, dupWarning)
-            else:
-                assert isSource(s), "Argument " + str(s) + \
-                    " in addSource is not a valid source for Collection"
-                if dupWarning is True:
-                    addUniqueSource(s, self.sources)
-                else:
-                    self.sources += [s]
-
-    def getB(self, pos):
-        """
-        This method returns the magnetic field vector generated by the whole
-        collection at the argument position `pos` in units of [mT]
-
-        Parameters
-        ----------
-        pos : vec3 [mm]
-            Position where magnetic field should be determined.
-
-        Returns
-        -------
-        magnetic field vector : arr3 [mT]
-            Magnetic field at the argument position `pos` generated by the
-            collection in units of [mT].
-        """
-        Btotal = sum([s.getB(pos) for s in self.sources])
-        return Btotal
-
-    def move(self, displacement):
-        """
-        This method moves each source in the collection by the argument vector `displacement`. 
-        Vector input format can be either list, tuple or array of any data
-        type (float, int).
-
-        Parameters
-        ----------
-        displacement : vec3 - [mm]
-            Displacement vector
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source, Collection
-        >>> pm1 = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1])
-        >>> pm2 = source.magnet.Cylinder(mag=[0,0,1000],dim=[1,1])
-        >>> print(pm1.position,pm2.position)
-          [0. 0. 0.] [0. 0. 0.]
-        >>> col = Collection(pm1,pm2)
-        >>> col.move([1,1,1])
-        >>> print(pm1.position,pm2.position)
-          [1. 1. 1.] [1. 1. 1.]
-        """
-        for s in self.sources:
-            s.move(displacement)
-
-    def rotate(self, angle, axis, anchor='self.position'):
-        """
-        This method rotates each source in the collection about `axis` by `angle`. The axis passes
-        through the center of rotation anchor. Scalar input is either integer or
-        float. Vector input format can be either list, tuple or array of any
-        data type (float, int).
-
-        Parameters
-        ----------
-        angle  : scalar [deg]
-            Angle of rotation in units of [deg]
-        axis : vec3
-            Axis of rotation
-        anchor : vec3
-            The Center of rotation which defines the position of the axis of rotation.
-            If not specified all sources will rotate about their respective center.
-
-        Returns
-        -------
-        None
-
-        Example
-        -------
-        >>> from magpylib import source, Collection
-        >>> pm1 = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1])
-        >>> pm2 = source.magnet.Cylinder(mag=[0,0,1000],dim=[1,1])
-        >>> print(pm1.position, pm1.angle, pm1.axis)
-          [0. 0. 0.] 0.0 [0. 0. 1.]
-        >>> print(pm2.position, pm2.angle, pm2.axis)
-          [0. 0. 0.] 0.0 [0. 0. 1.]
-        >>> col = Collection(pm1,pm2)
-        >>> col.rotate(90, [0,1,0], anchor=[1,0,0])
-        >>> print(pm1.position, pm1.angle, pm1.axis)
-          [1. 0. 1.] 90.0 [0. 1. 0.]
-        >>> print(pm2.position, pm2.angle, pm2.axis)
-          [1. 0. 1.] 90.0 [0. 1. 0.]
-        """
-        for s in self.sources:
-            s.rotate(angle, axis, anchor=anchor)
\ No newline at end of file
diff --git a/magpylib/_lib/classes/currents.py b/magpylib/_lib/classes/currents.py
deleted file mode 100644
index f395a981a..000000000
--- a/magpylib/_lib/classes/currents.py
+++ /dev/null
@@ -1,302 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import array, float64, ndarray, shape, ones, tile
-from magpylib._lib.mathLib import angleAxisRotation_priv
-from magpylib._lib.fields.Current_Line_vector import Bfield_CurrentLineV, Bfield_CurrentLineVV
-from magpylib._lib.fields.Current_CircularLoop import Bfield_CircularCurrentLoop
-from magpylib._lib.fields.Current_CircularLoop_vector import Bfield_CircularCurrentLoopV
-from magpylib._lib.classes.base import LineCurrent
-from magpylib._lib.mathLib_vector import angleAxisRotationV_priv
-
-
-# tool-tip / intellisense helpers ---------------------------------------------
-# Class initialization is done purely by kwargs. While some # of these can be 
-# set to zero by default other MUST be given to make any sense 
-# (e.g. magnetization). To improve tool tips and intellisense we inizilize them
-# with names, e.g. mag=(Mx, My, Mz). This looks good, but it requires that
-# these names are pre-initialzed:
-from typing import List, Tuple, TypeVar
-x_i = TypeVar('x_i', int, float)
-y_i = TypeVar('y_i', int, float)
-z_i = TypeVar('z_i', int, float)
-listOfPos = List[Tuple[x_i, y_i, z_i]]
-
-I = .0 
-d = .0
-
-
-
-# -------------------------------------------------------------------------------
-class Circular(LineCurrent):
-    """ 
-    A circular line current loop with diameter `dim` and a current `curr` flowing
-    in positive orientation. In the canonical basis (position=[0,0,0], angle=0.0,
-    axis=[0,0,1]) the loop lies in the x-y plane with the origin at its center.
-    Scalar input is either integer or float. Vector input format can be
-    either list, tuple or array of any data type (float, int).
-
-    Parameters
-    ----------
-
-    curr : scalar [A]
-        Set current in loop in units of [A]
-
-    dim : float [mm]
-        Set diameter of current loop in units of [mm]
-
-    pos=[0,0,0] : vec3 [mm]
-        Set position of the center of the current loop in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of current loop in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the current loop.
-
-    Attributes
-    ----------
-
-    current : float [A]
-        Current in loop in units of [A]
-
-    dimension : float [mm]
-        Loop diameter in units of [mm]
-
-    position : arr3 [mm]
-        Position of center of loop in units of [mm]
-
-    angle : float [deg]
-        Angle of orientation of the current loop.
-
-    axis : arr3 []
-        Axis of orientation of the current loop.
-
-    Example
-    -------
-    >>> from magpylib import source
-    >>> cd = source.current.Circular(curr=10,dim=2)
-    >>> B = cd.getB([0,0,2])
-    >>> print(B)
-      [0.         0.         0.56198518]
-
-    Note
-    ----
-    The following Methods are available to all sources objects.
-    """
-
-    def __init__(self, curr=I, dim=d, pos=(0.0, 0.0, 0.0), angle=0.0, axis=(0.0, 0.0, 1.0)):
-
-        # inherit class lineCurrent
-        #   - pos, Mrot, MrotInv, curr
-        #   - moveBy, rotateBy
-        LineCurrent.__init__(self, pos, angle, axis, curr)
-
-        # secure input type and check input format of dim
-        assert dim >= 0, 'Bad input dimension'
-        self.dimension = float(dim)
-
-    def getB(self, pos):  # Particular Circular current B field calculation. Check RCS for getB() interface
-        
-        # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = ones(NN)*self.angle
-                AX = tile(self.axis,(NN,1))
-                DIM = ones(NN)*self.dimension
-                CURR = ones(NN)*self.current                
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_CircularCurrentLoopV(CURR,DIM,ROTATEDPOS)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-        
-        
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_CircularCurrentLoop(self.current,self.dimension,rotatedPos))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        return BCm
-
-    def __repr__(self):
-        """
-         This is for the IPython Console
-        When you call a defined circular, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> c = source.current.Circular(2.45, 3.1469, [4.4, 5.24, 0.5])
-        >>> c
-            type: current.Circular 
-            current: 2.45  
-            dimension: d: 3.1469 
-            position: x: 4.4, y: 5.24, z: 0.5
-            angle: 0.0 
-            axis: x: 0.0, y: 0.0, z: 1.0
-        """
-        return "type: {} \n current: {}  \n dimension: d: {} \n position: x: {}, y: {}, z: {} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format("current.Circular", self.current, self.dimension, *self.position, self.angle, *self.axis)
-
-
-
-# -------------------------------------------------------------------------------
-class Line(LineCurrent):
-    """ 
-
-    A line current flowing along linear segments from vertex to vertex given by
-    a list of positions `vertices` in the canonical basis (position=[0,0,0], angle=0.0,
-    axis=[0,0,1]). Scalar input is either integer or float. Vector input format
-    can be either list, tuple or array of any data type (float, int).
-
-
-    Parameters
-    ----------
-
-    curr : scalar [A]
-        Set current in loop in units of [A]
-
-    vertices : vecNx3 [mm]
-        N positions given in units of [mm] that make up N-1 linear segments
-        along which the current `curr` flows, starting from the first position
-        and ending with the last one.
-        [[x,y,z], [x,y,z], ...]
-        "[pos1,pos2,...]"
-    pos=[0,0,0] : vec3 [mm]
-        Set reference position of the current distribution in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of current distribution in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the current distribution.
-
-    Attributes
-    ----------
-
-    current : float [A]
-        Current flowing along line in units of [A].
-
-    vertices : arrNx3 [mm]
-        Positions of line current vertices in units of [mm].
-
-    position : arr3 [mm]
-        Reference position of line current in units of [mm].
-
-    angle : float [deg]
-        Angle of orientation of line current in units of [deg].
-
-    axis : arr3 []
-        Axis of orientation of the line current.
-
-    Examples
-    --------
-    >>> from magpylib import source
-    >>> from numpy import sin,cos,pi,linspace
-    >>> vertices = [[cos(phi),sin(phi),0] for phi in linspace(0,2*pi,36)]
-    >>> cd = source.current.Line(curr=10,vertices=vertices)
-    >>> B = cd.getB([0,0,2])
-    >>> print(B)
-      [-6.24500451e-17  1.73472348e-18  5.59871233e-01]
-
-
-    Note
-    ----
-    The following Methods are available to all sources objects.
-    """
-
-    def __init__(self, curr=I, vertices=listOfPos, pos=(0.0, 0.0, 0.0), angle=0.0, axis=(0.0, 0.0, 1.0)):
-
-        # inherit class lineCurrent
-        #   - pos, Mrot, MrotInv, curr
-        #   - moveBy, rotateBy
-        LineCurrent.__init__(self, pos, angle, axis, curr)
-
-        # secure input type and check input format of dim
-        assert isinstance(vertices, list) or isinstance(
-            vertices, ndarray), 'Line Current: enter a list of position vertices - Ex: Line(vertices=[(1,2,3),(3,2,1)])'
-        assert all(isinstance(pos, tuple) or isinstance(pos, list)
-                   or isinstance(pos, ndarray) for pos in vertices), 'Line-current: Input position (3D) tuples or lists within the list - Ex: Line(vertices=[(1,2,3),(3,2,1)])'
-        assert all(len(
-            d) == 3 for d in vertices), 'Line-current: Bad input dimension, vectors in list must be 3D'
-        self.vertices = array(vertices, dtype=float64, copy=False)
-
-    def getB(self, pos):  # Particular Line current B field calculation. Check RCS for getB() interface
-        
-        # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = ones(NN)*self.angle
-                AX = tile(self.axis,(NN,1))
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_CurrentLineVV(self.vertices,self.current,ROTATEDPOS)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-        
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_CurrentLineV(self.vertices, self.current,rotatedPos))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        return BCm
-
-    def __repr__(self):
-        """
-        This is for the IPython Console
-        When you call a defined line, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> l = source.current.Line(2.45, [[2, .35, 2], [10, 2, -4], [4, 2, 1], [102, 2, 7]], [4.4, 5.24, 0.5])
-        >>> l
-            type: current.Line 
-            current: 2.45 
-            dimensions: vertices
-            position: x: 4.4, y: 5.24, z: 0.5
-            angle: 0.0 
-            axis: x: 0.0, y: 0.0, z: 1.0
-        """
-        return "type: {} \n current: {} \n dimensions: vertices \n position: x: {}, y: {}, z: {} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format("current.Line", self.current, *self.position, self.angle, *self.axis)
diff --git a/magpylib/_lib/classes/magnets.py b/magpylib/_lib/classes/magnets.py
deleted file mode 100644
index a3fd2534d..000000000
--- a/magpylib/_lib/classes/magnets.py
+++ /dev/null
@@ -1,437 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for calculating magnetic fields from
-# permanent magnets and current distributions.
-# Copyright (C) 2019  Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/OrtnerMichael/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import array, float64, ndarray
-from magpylib._lib.mathLib import angleAxisRotation_priv
-from magpylib._lib.utility import checkDimensions
-from magpylib._lib.classes.base import HomoMag
-from magpylib._lib.fields.PM_Sphere import Bfield_Sphere
-from magpylib._lib.fields.PM_Cylinder import Bfield_Cylinder
-from magpylib._lib.fields.PM_Box import Bfield_Box
-from magpylib._lib.mathLib_vector import angleAxisRotationV_priv
-import numpy as np
-from magpylib._lib.fields.PM_Box_vector import Bfield_BoxV
-from magpylib._lib.fields.PM_Cylinder_vector import Bfield_CylinderV
-from magpylib._lib.fields.PM_Sphere_vector import Bfield_SphereV
-
-# tool-tip / intellisense helpers ---------------------------------------------
-# Class initialization is done purely by kwargs. While some # of these can be 
-# set to zero by default other MUST be given to make any sense 
-# (e.g. magnetization). To improve tool tips and intellisense we inizilize them
-# with names, e.g. mag=(Mx, My, Mz). This looks good, but it requires that
-# these names are pre-initialzed:
-Mx = My = Mz = .0
-a = b = c = .0
-d = .0 
-h = .0
-
-
-
-# -----------------------------------------------------------------------------
-class Box(HomoMag):
-    """ 
-    A homogeneously magnetized cuboid magnet. In 
-    the canonical basis (position=[0,0,0], angle=0.0, axis=[0,0,1]) the magnet
-    has the origin at its geometric center and the sides of the box are parallel
-    to the basis vectors. Scalar input is either integer or float. 
-    Vector input format can be either list, tuple or array of any data type (float, int).
-
-
-    Parameters
-    ----------
-
-    mag : vec3 [mT]
-        Set magnetization vector of magnet in units of [mT].
-
-    dim : vec3 [mm]
-        Set the size of the box. dim=[a,b,c] which anchorresponds to the three
-        side lenghts of the box in units of [mm].
-
-    pos=[0,0,0] : vec3 [mm]
-        Set position of the center of the magnet in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of magnet in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the magnet.
-
-    Attributes
-    ----------
-    magnetization : arr3 [mT]
-        Magnetization vector of box in units of [mT].
-
-    dimension : arr3 [mm]
-        Magnet dimension=[a,b,c] which anchorrespond to the three side lenghts
-        of the box in units of [mm] in x-,y- and z-direction respectively
-        in the canonical basis.
-
-    position : arr3 [mm]
-        Position of the center of the magnet in units of [mm].
-
-    angle : float [deg]
-        Angle of orientation of the magnet in units of [deg].
-
-    axis : arr3 []
-        Axis of orientation of the magnet.
-
-    Example
-    -------
-    >>> from magpylib import source
-    >>> pm = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1])
-    >>> B = pm.getB([1,0,1])
-    >>> print(B)
-      [4.29223532e+01 1.76697482e-14 1.37461635e+01]
-
-    Note
-    ----
-    The following Methods are available to all sources objects.
-    """
-
-    def __init__(self, mag=(Mx, My, Mz), dim=(a, b, c), pos=(0.0, 0.0, 0.0), angle=0.0, axis=(0.0, 0.0, 1.0)):
-        # inherit class HomoMag
-        HomoMag.__init__(self, pos, angle, axis, mag)
-
-        # secure input type and check input format of dim
-        self.dimension = checkDimensions(3, dim, "Bad dim for box")
-
-    def getB(self, pos):
-        
-        # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(np.shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = np.shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = np.ones(NN)*self.angle
-                AX = np.tile(self.axis,(NN,1))
-                MAG = np.tile(self.magnetization,(NN,1))
-                DIM = np.tile(self.dimension,(NN,1))
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_BoxV(MAG,ROTATEDPOS,DIM)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_Box(self.magnetization, rotatedPos, self.dimension))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        
-        
-        return BCm
-        
-    def __repr__(self):
-        """
-        This is for the IPython Console
-        When you call a defined box, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> b = source.magnet.Box([0.2,32.5,5.3], [1.0,2.4,5.0], [1.0,0.2,3.0])
-        >>> b
-            type: magnet.Box 
-            magnetization: x: 0.2, y: 32.5, z: 5.3
-            dimensions: a: 1.0, b: 2.4, c: 5.0
-            position: x: 1.0, y:0.2, z: 3.0
-            angle: 0.0  
-            axis: x: 0.0, y: 0.0, z:1.0
-        """
-        return "type: {} \n magnetization: x: {}, y: {}, z: {} \n dimensions: a: {}, b: {}, c: {} \n position: x: {}, y:{}, z: {} \n angle: {} Degrees \n axis: x: {}, y: {}, z:{}".format("magnet.Box", *self.magnetization, *self.dimension, *self.position, self.angle, *self.axis)
-
-
-
-# -----------------------------------------------------------------------------
-class Cylinder(HomoMag):
-    """ 
-    A homogeneously magnetized cylindrical magnet. 
-    The magnet is initialized in the canonical basis (position=[0,0,0],
-    angle=0.0, axis=[0,0,1]) with the geometric center at the origin and the
-    central symmetry axis pointing in z-direction so that the circular bottom
-    lies in a plane parallel to the xy-plane. Scalar input is either integer
-    or float and reflects a round bottom. 
-    Vector input format can be either list, tuple or array of any
-    data type (float, int).
-
-    Parameters
-    ----------
-    mag : vec3 [mT]
-        Set magnetization vector of magnet in units of [mT].
-
-    dim : vec2 [mm]
-        Set the size of the cylinder. dim=[D,H] which are diameter and height
-        of the cylinder in units of [mm] respectively.
-
-    pos=[0,0,0] : vec3 [mm]
-        Set position of the center of the magnet in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of magnet in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the magnet.
-
-    iterDia=50 : int []
-        Set number of iterations for calculation of B-field from non-axial 
-        magnetization. Lower values will make the calculation faster but
-        less precise.
-
-    Attributes
-    ----------
-    magnetization : arr3 [mT]
-        Magnetization vector of magnet in units of [mT].
-
-    dimension : arr2 [mm]
-        Magnet dimension=[d,h] which anchorrespond to diameter and height of the
-        cylinder in units of [mm].
-
-    position : arr3 [mm]
-        Position of the center of the magnet in units of [mm].
-
-    angle : float [deg]
-        Angle of orientation of the magnet in units of [deg].
-
-    axis : arr3 []
-        Axis of orientation of the magnet.
-
-    iterDia : int []
-        Number of iterations for calculation of B-field from non-axial
-        magnetization. Lower values will make the calculation faster but less
-        precise.
-
-    Example
-    -------
-    >>> from magpylib import source
-    >>> pm = source.magnet.Cylinder(mag=[0,0,1000],dim=[1,1])
-    >>> B = pm.getB([1,0,1])
-    >>> print(B)
-      [34.31662243  0.         10.16090915]
-
-    Note
-    ----
-    The following Methods are available to all sources objects.
-    """
-
-    def __init__(self, mag=(Mx, My, Mz), dim=(d, h), pos=(0.0, 0.0, 0.0), angle=0.0, axis=(0.0, 0.0, 1.0), iterDia=50):
-
-        # inherit class homoMag
-        #   - pos, Mrot, MrotInv, mag
-        #   - moveBy, rotateBy
-        HomoMag.__init__(self, pos, angle, axis, mag)
-
-        # secure input type and check input format of dim
-        assert type(
-            iterDia) == int, 'Bad iterDia input for cylinder, expected <class int> got ' + str(type(iterDia))
-        self.dimension = checkDimensions(2, dim, "Bad dim input for cylinder")
-        self.iterDia = iterDia
-
-    def getB(self, pos):  # Particular Cylinder B field calculation. Check RCS for getB() interface
-        
-        # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(np.shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = np.shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = np.ones(NN)*self.angle
-                AX = np.tile(self.axis,(NN,1))
-                MAG = np.tile(self.magnetization,(NN,1))
-                DIM = np.tile(self.dimension,(NN,1))
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_CylinderV(MAG,ROTATEDPOS,DIM,self.iterDia)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-        
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_Cylinder(self.magnetization, rotatedPos, self.dimension, self.iterDia))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        return BCm
-
-    def __repr__(self):
-        """
-        This is for the IPython Console
-        When you call a defined cylinder, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> c = source.magnet.Cylinder([0.2,32.5,5.3], [2.0,9.0], [1.0,0.2,3.0])
-        >>> c
-            type: magnet.Cylinder 
-            magnetization: x: 0.2, y: 32.5, z: 5.3
-            dimensions: d: 2.0, h: 9.0 
-            position: x: 1.0, y:0.2, z: 3.0
-            angle: 0.0 
-            axis: x: 0.0, y: 0.0, z:1.0
-        """
-        return "type: {} \n magnetization: x: {}, y: {}, z: {} \n dimensions: d: {}, h: {} \n position: x: {}, y:{}, z: {} \n angle: {} \n axis: x: {}, y: {}, z:{}".format("magnet.Cylinder", *self.magnetization, *self.dimension, *self.position, self.angle, *self.axis)
-
-
-
-# -----------------------------------------------------------------------------
-class Sphere(HomoMag):
-    """ 
-    A homogeneously magnetized sphere. The magnet
-    is initialized in the canonical basis (position=[0,0,0],
-    angle=0.0, axis=[0,0,1]) with the center at the origin. Scalar input is
-    either integer or float. Vector input format can be either list, tuple
-    or array of any data type (float, int).
-
-    Parameters
-    ----------
-
-    mag : vec3 [mT]
-        Set magnetization vector of magnet in units of [mT].
-
-    dim : float [mm]
-        Set diameter of the sphere in units of [mm].
-
-    pos=[0,0,0] : vec3 [mm]
-        Set position of the center of the magnet in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of magnet in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the magnet.
-
-    Attributes
-    ----------
-
-    magnetization : arr3 [mT]
-        Magnetization vector of magnet in units of [mT].
-
-    dimension : float [mm]
-        Sphere diameter in units of [mm].
-
-    position : arr3 [mm]
-        Position of the center of the magnet in units of [mm].
-
-    angle : float [deg]
-        Angle of orientation of the magnet in units of [deg].
-
-    axis : arr3 []
-        Axis of orientation of the magnet.
-
-    Example
-    -------
-    >>> from magpylib import source
-    >>> pm = source.magnet.Sphere(mag=[0,0,1000],dim=1)
-    >>> B = pm.getB([1,0,1])
-    >>> print(B)
-      [22.09708691  0.          7.36569564]
-
-    Note
-    ----
-    The following Methods are available to all sources objects.
-    """
-
-    def __init__(self, mag=(Mx, My, Mz), dim=d, pos=(0.0, 0.0, 0.0), angle=0.0, axis=(0.0, 0.0, 1.0)):
-
-        # inherit class homoMag
-        #   - pos, Mrot, MrotInv, mag
-        #   - moveBy, rotateBy
-        HomoMag.__init__(self, pos, angle, axis, mag)
-
-        # secure input type and check input format of dim
-        self.dimension = float(dim)
-        assert self.dimension > 0, 'Bad dim<=0 for sphere'
-
-    def getB(self, pos):
-
-        # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(np.shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = np.shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = np.ones(NN)*self.angle
-                AX = np.tile(self.axis,(NN,1))
-                MAG = np.tile(self.magnetization,(NN,1))
-                DIM = np.ones(NN)*self.dimension
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_SphereV(MAG,ROTATEDPOS,DIM)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_Sphere(self.magnetization, rotatedPos, self.dimension))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        return BCm
-
-    def __repr__(self):
-        """
-        This is for the IPython Console
-        When you call a defined sphere, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> s = source.magnet.Sphere([0.2,32.5,5.3], 1.0, [1.0,0.2,3.0])
-        >>> s
-            type: magnet.Sphere 
-            magnetization: x: 0.2, y: 32.5, z: 5.3
-            dimensions: d: 1.0 
-            position: x: 1.0, y:0.2, z: 3.0
-            angle: 0.0  
-            axis: x: 0.0, y: 0.0, z:1.0
-        """
-        return "type: {} \n magnetization: x: {}, y: {}, z: {}mT \n dimensions: d: {} \n position: x: {}, y:{}, z: {} \n angle: {} Degrees \n axis: x: {}, y: {}, z:{}".format("magnet.Sphere", *self.magnetization, self.dimension, *self.position, self.angle, *self.axis)
-
-
-
-# -----------------------------------------------------------------------------
-class Facet(HomoMag):
-    """
-    WIP
-    """
-    def __init__(self):
-        print('Facet class is work in progress')
\ No newline at end of file
diff --git a/magpylib/_lib/classes/moments.py b/magpylib/_lib/classes/moments.py
deleted file mode 100644
index ecf1312d6..000000000
--- a/magpylib/_lib/classes/moments.py
+++ /dev/null
@@ -1,141 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import array, float64, ndarray, ones, tile, shape
-from magpylib._lib.mathLib import angleAxisRotation_priv
-from magpylib._lib.classes.base import MagMoment
-from magpylib._lib.fields.Moment_Dipole import Bfield_Dipole
-from magpylib._lib.fields.Moment_Dipole_vector import Bfield_DipoleV
-from magpylib._lib.mathLib_vector import angleAxisRotationV_priv
-
-# tool-tip / intellisense helpers ---------------------------------------------
-# Class initialization is done purely by kwargs. While some # of these can be 
-# set to zero by default other MUST be given to make any sense 
-# (e.g. magnetization). To improve tool tips and intellisense we inizilize them
-# with names, e.g. mag=(Mx, My, Mz). This looks good, but it requires that
-# these names are pre-initialzed:
-Mx = My = Mz = 0.0
-
-# -------------------------------------------------------------------------------
-class Dipole(MagMoment):
-    """ 
-    This class represents a magnetic dipole. The dipole is constructed such that 
-    its moment :math:`|M|` is given in :math:`[mT*mm^3]` and corresponds to the moment of a cuboid
-    magnet with remanence field Br and Volume V such that :math:`|M| = Br*V`. Scalar
-    input is either integer or float. Vector input format can be either list,
-    tuple or array of any data type (float, int).
-
-
-    Parameters
-    ----------
-
-    moment : vec3 [mT]
-        Set magnetic dipole moment in units of [mT*mm^3].
-
-    pos=[0,0,0] : vec3 [mm]
-        Set position of the moment in units of [mm].
-
-    angle=0.0 : scalar [deg]
-        Set angle of orientation of the moment in units of [deg].
-
-    axis=[0,0,1] : vec3 []
-        Set axis of orientation of the moment.
-
-    Attributes
-    ----------
-
-    moment : arr3 [mT]
-        Magnetic dipole moment in units of [mT*mm^3] (:math:`|moment| = Br*V` of a
-        cuboid magnet.)
-
-    position : arr3 [mm]
-        Position of the moment in units of [mm].
-
-    angle : float [deg]
-        Angle of orientation of the moment in units of [deg].
-
-    axis : arr3 []
-        Axis of orientation of the moment.
-
-    Examples
-    --------
-    >>> magpylib as magpy
-    >>> mom = magpy.source.moment.Dipole(moment=[0,0,1000])
-    >>> B = mom.getB([1,0,1])
-    >>> print(B)
-      [0.33761862  0.  0.11253954]
-
-    Note
-    ----
-    The following Methods are available to all source objects.
-    """
-
-    def getB(self, pos):  # Particular Line current B field calculation. Check RCS for getB() interface
-        
-         # vectorized code if input is an Nx3 array
-        if type(pos) == ndarray:
-            if len(shape(pos))==2: # list of positions - use vectorized code
-                # vector size
-                NN = shape(pos)[0] 
-                # prepare vector inputs
-                POSREL = pos - self.position
-                ANG = ones(NN)*self.angle
-                AX = tile(self.axis,(NN,1))
-                MOM = tile(self.moment,(NN,1))
-                # compute rotations and field
-                ROTATEDPOS = angleAxisRotationV_priv(ANG, -AX, POSREL)
-                BB = Bfield_DipoleV(MOM,ROTATEDPOS)
-                BCM = angleAxisRotationV_priv(ANG, AX, BB)
-
-                return BCM
-        
-        # secure input type and check input format
-        p1 = array(pos, dtype=float64, copy=False)
-        # relative position between mag and obs
-        posRel = p1 - self.position
-        # rotate this vector into the CS of the magnet (inverse rotation)
-        rotatedPos = angleAxisRotation_priv(self.angle, -self.axis, posRel) # pylint: disable=invalid-unary-operand-type
-        # rotate field vector back
-        BCm = angleAxisRotation_priv(self.angle, self.axis, Bfield_Dipole(self.moment, rotatedPos))
-        # BCm is the obtained magnetic field in Cm
-        # the field is well known in the magnet coordinates.
-        return BCm
-
-    def __repr__(self):
-        """
-        This is for the IPython Console
-        When you call a defined dipole, this method shows you all its components.
-
-        Examples
-        --------
-        >>> from magpylib import source
-        >>> d = source.moment.Dipole([1.0,2.0,3.0], [3.0,2.0,1.0])
-        >>> d
-            name: Dipole 
-            moment: x: 1.0mT, y: 2.0mT, z: 3.0mT 
-            position: x: 3.0mm, y: 2.0mm, z:1.0mm 
-            angle: 0.0 Degrees 
-            axis: x: 0.0, y: 0.0, z: 1.0
-        """
-        return "type: {} \n moment: x: {}, y: {}, z: {} \n position: x: {}, y: {}, z:{} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format("moments.Dipole", *self.moment, *self.position, self.angle, *self.axis)
diff --git a/magpylib/_lib/classes/sensor.py b/magpylib/_lib/classes/sensor.py
deleted file mode 100644
index 787064350..000000000
--- a/magpylib/_lib/classes/sensor.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from magpylib._lib.classes.base import RCS
-from magpylib._lib.utility import addUniqueSource, addListToCollection, isSource
-from magpylib._lib.mathLib import angleAxisRotation
-
-class Sensor(RCS):
-    """
-    Create a rotation-enabled sensor to extract B-fields from individual Sources and Source Collections.
-    It may be displayed with :class:`~magpylib.Collection`'s :meth:`~magpylib.Collection.displaySystem` using the sensors kwarg.
-
-    Parameters
-    ----------
-    position : vec3
-        Cartesian position of where the sensor is.
-
-    angle : scalar
-        Angle of rotation
-
-    axis : vec3
-        Rotation axis information (x,y,z)
-
-    Example
-    -------
-        >>> from magpylib import source, Sensor
-        >>> sensor = Sensor([0,0,0],90,(0,0,1)) # This sensor is rotated in respect to space
-        >>> cyl = source.magnet.Cylinder([1,2,300],[1,2])
-        >>> absoluteReading = cyl.getB([0,0,0])
-        >>> print(absoluteReading)
-            [  0.552   1.105  268.328 ]
-        >>> relativeReading = sensor.getB(cyl)
-        >>> print(relativeReading)
-            [  1.105  -0.552  268.328 ]
-    """
-
-    def __init__(self, pos=[0, 0, 0], angle=0, axis=[0, 0, 1]):
-        RCS.__init__(self, pos, angle, axis)
-
-    def __repr__(self):
-        return f"\n name: Sensor"\
-               f"\n position x: {self.position[0]} mm  n y: {self.position[1]}mm z: {self.position[2]}mm"\
-               f"\n angle: {self.angle} Degrees"\
-               f"\n axis: x: {self.axis[0]}   n y: {self.axis[1]} z: {self.axis[2]}"
-
-    def getB(self, *sources, dupWarning=True):
-        """Extract the magnetic field based on the Sensor orientation
-
-        Parameters
-        ----------
-        dupWarning : Check if there are any duplicate sources, optional.
-            This will prevent duplicates and throw a warning, by default True.
-
-        Returns
-        -------
-        [vec3]
-            B-Field as perceived by the sensor
-
-        Example
-        -------
-        >>> from magpylib import source, Sensor
-        >>> sensor = Sensor([0,0,0],90,(0,0,1)) # This sensor is rotated in respect to space
-        >>> cyl = source.magnet.Cylinder([1,2,300],[1,2])
-        >>> absoluteReading = cyl.getB([0,0,0])
-        >>> print(absoluteReading)
-            [  0.552   1.105  268.328 ]
-        >>> relativeReading = sensor.getB(cyl)
-        >>> print(relativeReading)
-            [  1.105  -0.552  268.328 ]
-        """
-        # Check input, add Collection list
-        sourcesList = []
-        for s in sources:
-            try:
-                addListToCollection(sourcesList, s.sources, dupWarning)
-            except AttributeError:
-                if isinstance(s, list) or isinstance(s, tuple):
-                    addListToCollection(sourcesList, s, dupWarning)
-                else:
-                    assert isSource(s), "Argument " + str(s) + \
-                        " in addSource is not a valid source for Collection"
-                    if dupWarning is True:
-                        addUniqueSource(s, sourcesList)
-                    else:
-                        sourcesList += [s]
-
-        # Read the field from all nominated sources
-        Btotal = sum([s.getB(self.position) for s in sources])
-        return angleAxisRotation(Btotal,
-                              -self.angle,  # Rotate in the opposite direction
-                              self.axis,
-                              [0, 0, 0])
diff --git a/magpylib/_lib/displaySystem.py b/magpylib/_lib/displaySystem.py
deleted file mode 100644
index f77ce0571..000000000
--- a/magpylib/_lib/displaySystem.py
+++ /dev/null
@@ -1,410 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from copy import deepcopy
-import matplotlib.pyplot as plt
-from mpl_toolkits.mplot3d.art3d import Poly3DCollection
-from numpy import array, amax, linspace, pi, sin, cos, finfo
-from magpylib._lib.classes.magnets import Box, Cylinder, Sphere
-from magpylib._lib.classes.currents import Line, Circular
-from magpylib._lib.classes.moments import Dipole
-from magpylib._lib.classes.sensor import Sensor
-from magpylib._lib.classes.base import FieldSampler
-from magpylib._lib.utility import drawCurrentArrows, drawMagAxis, drawDipole, isDisplayMarker
-from magpylib._lib.utility import drawSensor, isSensor
-from magpylib._lib.mathLib import angleAxisRotation_priv
-from magpylib._lib.mathLib import angleAxisRotation
-from magpylib import Collection
-
-
-# tool-tip / intellisense helpers -----------------------------------------------
-# Class initialization is done purely by kwargs. While some # of these can be 
-# set to zero by default other MUST be given to make any sense 
-# (e.g. magnetization). To improve tool tips and intellisense we inizilize them
-# with names, e.g. mag=(Mx, My, Mz). This looks good, but it requires that
-# these names are pre-initialzed:
-x=y=z=0.0 # Position Vector
-sensor1=sensor2="sensor type"
-numpyArray=[[x,y,z]] # List of Positions
-listOfPos=[[x,y,z]] # List of Positions
-listOfSensors=[sensor1,sensor2] # List of Sensors
-
-
-
-# -------------------------------------------------------------------------------
-def displaySystem(sources, markers=listOfPos, subplotAx=None,
-                        sensors=listOfSensors, suppress=False, 
-                        direc=False, figsize=(8, 8)):
-    """
-    Shows the collection system in an interactive pyplot and returns a matplotlib figure identifier.
-
-    WARNING
-    -------
-    As a result of an inherent problem in matplotlib the 
-    Poly3DCollections z-ordering fails when bounding boxes intersect.
-
-
-    Parameters
-    ----------
-    markers : list[scalar,scalar,scalar,[label]]
-        List of position vectors to add visual markers to the display, optional label.
-        Default: [[0,0,0]]
-
-    Example
-    -------
-    >>> from magpylib import Collection, source
-    >>> c=source.current.Circular(3,7)
-    >>> x = Collection(c)
-    >>> marker0 = [0,0,0,"Neutral Position"]
-    >>> marker1 = [10,10,10]
-    >>> x.displaySystem(markers=[ marker0,
-    ...                           marker1])
-
-    Parameters
-    ----------
-    sensors : list[sensor]
-        List of :class:`~magpylib.Sensor` objects to add the display.
-        Default: None
-
-    Example
-    -------
-    >>> from magpylib import Collection, source
-    >>> c=source.current.Circular(3,7)
-    >>> x = Collection(c)
-    >>> sensor0 = Sensor()
-    >>> sensor1 = Sensor(pos=[1,2,3], angle=180)
-    >>> x.displaySystem(sensors=[ sensor0,
-    ...                           sensor1])
-
-
-    Parameters
-    ----------
-    suppress : bool
-        If True, only return Figure information, do not show. Interactive mode must be off.
-        Default: False.
-
-
-    Example
-    -------
-    >>> ## Suppress matplotlib.pyplot.show() 
-    >>> ## and returning figure from showing up
-    >>> from matplotlib import pyplot 
-    >>> pyplot.ioff()
-    >>> figureData = Collection.displayFigure(suppress=True)
-
-    Parameters
-    ----------
-    direc : bool
-        Set to True to show current directions and magnetization vectors.
-        Default: False
-
-
-    Return    
-    ------
-    matplotlib Figure object
-        graphics object is displayed through plt.show()
-
-    Example
-    -------
-    >>> from magpylib import source, Collection
-    >>> pm1 = source.magnet.Box(mag=[0,0,1000],dim=[1,1,1],pos=[-1,-1,-1],angle=45,axis=[0,0,1])
-    >>> pm2 = source.magnet.Cylinder(mag=[0,0,1000],dim=[2,2],pos=[0,-1,1],angle=45,axis=[1,0,0])
-    >>> pm3 = source.magnet.Sphere(mag=[0,0,1000],dim=3,pos=[-2,1,2],angle=45,axis=[1,0,0])
-    >>> C1 = source.current.Circular(curr=100,dim=6)
-    >>> col = Collection(pm1,pm2,pm3,C1)
-    >>> col.displaySystem()
-    
-    Parameters
-    ----------
-    subplotAx : matplotlib subplot axe instance
-        Use an existing matplotlib subplot instance to draw the 3D system plot into.
-        Default: None
-    
-    Example
-    -------
-    >>> import numpy as np
-    >>> import matplotlib.pyplot as plt
-    >>> from magpylib.source.magnet import Box
-    >>> from magpylib import Collection
-    >>> #create collection of one magnet
-    >>> s1 = Box(mag=[ 500,0, 500], dim=[3,3,3], pos=[ 0,0, 3], angle=45, axis=[0,1,0])
-    >>> c = Collection(s1)
-    >>> #create positions
-    >>> xs = np.linspace(-8,8,100)
-    >>> zs = np.linspace(-6,6,100)
-    >>> posis = [[x,0,z] for z in zs for x in xs]
-    >>> #calculate fields
-    >>> Bs = c.getBsweep(posis)
-    >>> #reshape array and calculate amplitude
-    >>> Bs = np.array(Bs).reshape([100,100,3])
-    >>> Bamp = np.linalg.norm(Bs,axis=2)
-    >>> X,Z = np.meshgrid(xs,zs)
-    >>> # Define figure
-    >>> fig = plt.figure()
-    >>> ## Define ax for 2D
-    >>> ax1 = fig.add_subplot(1, 2, 1, axisbelow=True)
-    >>> ## Define ax for 3D displaySystem
-    >>> ax2 = fig.add_subplot(1, 2, 2, axisbelow=True,projection='3d')
-    >>> ## field plot 2D
-    >>> ax1.contourf(X,Z,Bamp,100,cmap='rainbow')
-    >>> U,V = Bs[:,:,0], Bs[:,:,2]
-    >>> ax1.streamplot(X, Z, U, V, color='k', density=2)
-    >>> ## plot Collection system in 3D ax subplot
-    >>> c.displaySystem(subplotAx=ax2)
-    
-    Raises
-    ------
-    AssertionError
-        If Marker position list is poorly defined. i.e. listOfPos=(x,y,z) instead of lisOfPos=[(x,y,z)]
-    """
-    
-    collection = Collection(sources)
-
-    if subplotAx is None:
-        fig = plt.figure(dpi=80, figsize=figsize)
-        ax = fig.gca(projection='3d')
-    else:
-        ax = subplotAx
-
-    # count magnets
-    Nm = 0
-    for s in collection.sources:
-        if type(s) is Box or type(s) is Cylinder or type(s) is Sphere:
-            Nm += 1
-    
-    cm = plt.cm.hsv  # Linter complains about this but it is working pylint: disable=no-member
-    # select colors
-    colors = [cm(x) for x in linspace(0, 1, Nm+1)]
-
-    ii = -1
-    SYSSIZE = finfo(float).eps  # Machine Epsilon for moment
-    dipolesList = []
-    magnetsList = []
-    sensorsList = []
-    currentsList = []
-    markersList = []
-
-    # Check input and Add markers to the Markers list before plotting
-    for m in markers:
-        assert isDisplayMarker(m), "Invalid marker definition in displaySystem:" + str(
-            m) + ". Needs to be [vec3] or [vec3,string]"
-        markersList += [m]
-    
-    for s in sensors:    
-        if s == sensor1:
-            continue
-        else:
-            assert isSensor(s), "Invalid sensor definition in displaySystem:" + str(
-            s) 
-            sensorsList.append(s)
-
-    for s in collection.sources:
-        if type(s) is Box:
-            ii += 1  # increase color counter
-            P = s.position
-            D = s.dimension/2
-            # create vertices in canonical basis
-            v0 = array([D, D*array([1, 1, -1]), D*array([1, -1, -1]), D*array([1, -1, 1]),
-                        D*array([-1, 1, 1]), D*array([-1, 1, -1]), -D, D*array([-1, -1, 1])])
-            # rotate vertices + displace
-            v = array([angleAxisRotation_priv(s.angle, s.axis, d)+P for d in v0])
-            # create faces
-            faces = [[v[0], v[1], v[2], v[3]],
-                        [v[0], v[1], v[5], v[4]],
-                        [v[4], v[5], v[6], v[7]],
-                        [v[2], v[3], v[7], v[6]],
-                        [v[0], v[3], v[7], v[4]],
-                        [v[1], v[2], v[6], v[5]]]
-            # plot
-            boxf = Poly3DCollection(
-                faces, facecolors=colors[ii], linewidths=0.5, edgecolors='k', alpha=1)
-            ax.add_collection3d(boxf)
-            # check system size
-            maxSize = amax(abs(v))
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            if direc is True:
-                s.color = colors[ii]
-                magnetsList.append(s)
-        elif type(s) is Cylinder:
-            ii += 1  # increase color counter
-            P = s.position
-            R, H = s.dimension/2
-
-            resolution = 20
-
-            # vertices
-            phis = linspace(0, 2*pi, resolution)
-            vertB0 = array([[R*cos(p), R*sin(p), -H] for p in phis])
-            vertT0 = array([[R*cos(p), R*sin(p), H] for p in phis])
-            # rotate vertices+displacement
-            vB = array(
-                [angleAxisRotation_priv(s.angle, s.axis, d)+P for d in vertB0])
-            vT = array(
-                [angleAxisRotation_priv(s.angle, s.axis, d)+P for d in vertT0])
-            # faces
-            faces = [[vT[i], vB[i], vB[i+1], vT[i+1]]
-                        for i in range(resolution-1)]
-            faces += [vT, vB]
-            # plot
-            coll = Poly3DCollection(
-                faces, facecolors=colors[ii], linewidths=0.5, edgecolors='k', alpha=1)
-            ax.add_collection3d(coll)
-            # check system size
-            maxSize = max([amax(abs(vB)), amax(abs(vT))])
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            if direc is True:
-                s.color = colors[ii]
-                magnetsList.append(s)
-
-        elif type(s) is Sphere:
-            ii += 1  # increase color counter
-            P = s.position
-            R = s.dimension/2
-
-            resolution = 12
-
-            # vertices
-            phis = linspace(0, 2*pi, resolution)
-            thetas = linspace(0, pi, resolution)
-            vs0 = [[[R*cos(phi)*sin(th), R*sin(phi)*sin(th), R*cos(th)]
-                    for phi in phis] for th in thetas]
-            # rotate vertices + displacement
-            vs = array(
-                [[angleAxisRotation_priv(s.angle, s.axis, v)+P for v in vss] for vss in vs0])
-            # faces
-            faces = []
-            for j in range(resolution-1):
-                faces += [[vs[i, j], vs[i+1, j], vs[i+1, j+1], vs[i, j+1]]
-                            for i in range(resolution-1)]
-            # plot
-            boxf = Poly3DCollection(
-                faces, facecolors=colors[ii], linewidths=0.5, edgecolors='k', alpha=1)
-            ax.add_collection3d(boxf)
-            # check system size
-            maxSize = amax(abs(vs))
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            if direc is True:
-                s.color = colors[ii]
-                magnetsList.append(s)
-
-        elif type(s) is Line:
-            P = s.position
-            vs0 = s.vertices
-            # rotate vertices + displacement
-            vs = array(
-                [angleAxisRotation_priv(s.angle, s.axis, v)+P for v in vs0])
-            # plot
-            ax.plot(vs[:, 0], vs[:, 1], vs[:, 2], lw=1, color='k')
-            # check system size
-            maxSize = amax(abs(vs))
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            if direc is True:
-                # These don't move in the original object,
-                sCopyWithVertices = deepcopy(s)
-                sCopyWithVertices.vertices = vs  # We just draw the frame rotation, discard changes
-                currentsList.append(sCopyWithVertices)
-
-        elif type(s) is Circular:
-            P = s.position
-            R = s.dimension/2
-
-            resolution = 20
-
-            # vertices
-            phis = linspace(0, 2*pi, resolution)
-            vs0 = array([[R*cos(p), R*sin(p), 0] for p in phis])
-            # rotate vertices + displacement
-            vs = array(
-                [angleAxisRotation_priv(s.angle, s.axis, v)+P for v in vs0])
-            # plot
-            ax.plot(vs[:, 0], vs[:, 1], vs[:, 2], lw=1, color='k')
-            # check system size
-            maxSize = amax(abs(vs))
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            if direc is True:
-                # Send the Circular vertice information
-                sCopyWithVertices = deepcopy(s)
-                sCopyWithVertices.vertices = vs  # to the object drawing list
-                currentsList.append(sCopyWithVertices)
-
-        elif type(s) is Dipole:
-            P = angleAxisRotation(s.position, s.angle, s.axis)
-            maxSize = amax(abs(P))
-            if maxSize > SYSSIZE:
-                SYSSIZE = maxSize
-
-            dipolesList.append(s)
-
-    for m in markersList:  # Draw Markers
-        ax.scatter(m[0], m[1], m[2], s=20, marker='x')
-        if(len(m) > 3):
-            zdir = None
-            ax.text(m[0], m[1], m[2], m[3], zdir)
-        # Goes up to 3rd Position
-        maxSize = max([abs(pos) for pos in m[:3]])
-        if maxSize > SYSSIZE:
-            SYSSIZE = maxSize
-
-    for s in sensorsList: # Draw Sensors
-        maxSize = max([abs(pos) for pos in s.position])
-        if maxSize > SYSSIZE:
-            SYSSIZE = maxSize
-        drawSensor(s,SYSSIZE,ax)
-
-    for d in dipolesList:
-        drawDipole(d.position, d.moment,
-                    d.angle, d.axis,
-                    SYSSIZE, ax)
-
-    if direc is True:  # Draw the Magnetization axes and current directions
-        drawCurrentArrows(currentsList, SYSSIZE, ax)
-        drawMagAxis(magnetsList, SYSSIZE, ax)
-
-    #for tick in ax.xaxis.get_ticklabels()+ax.yaxis.get_ticklabels()+ax.zaxis.get_ticklabels():
-    #    tick.set_fontsize(12)
-    ax.set_xlabel('x[mm]')#, fontsize=12)
-    ax.set_ylabel('y[mm]')#, fontsize=12)   #change font size through rc parameters
-    ax.set_zlabel('z[mm]')#, fontsize=12)
-    ax.set(
-        xlim=(-SYSSIZE, SYSSIZE),
-        ylim=(-SYSSIZE, SYSSIZE),
-        zlim=(-SYSSIZE, SYSSIZE),
-    )
-    
-    plt.tight_layout()
-
-    if suppress == True:
-        return plt.gcf()
-    else:
-        plt.show()
\ No newline at end of file
diff --git a/magpylib/_lib/fields/Current_CircularLoop.py b/magpylib/_lib/fields/Current_CircularLoop.py
deleted file mode 100644
index d12fa3794..000000000
--- a/magpylib/_lib/fields/Current_CircularLoop.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import sqrt, array, NaN, cos, sin
-from magpylib._lib.mathLib import getPhi, ellipticK, ellipticE
-from warnings import warn
-
-# %% CIRCULAR CURRENT LOOP
-# Describes the magnetic field of a circular current loop that lies parallel to
-#    the x-y plane. The loop has the radius r0 and carries the current i0, the
-#    center of the current loop lies at posCL
-
-# i0   : float  [A]     current in the loop
-# d0   : float  [mm]    diameter of the current loop
-# posCL: arr3  [mm]     Position of the center of the current loop
-
-# source: calculation from Filipitsch Diplo
-
-
-def Bfield_CircularCurrentLoop(i0, d0, pos):
-
-    px, py, pz = pos
-
-    r = sqrt(px**2+py**2)
-    phi = getPhi(px, py)
-    z = pz
-
-    r0 = d0/2  # radius of current loop
-
-    # avoid singularity at CL
-    #    print('WARNING: close to singularity - setting field to zero')
-    #    return array([0,0,0])
-    rr0 = r-r0
-    if (-1e-12 < rr0 and rr0 < 1e-12):  # rounding to eliminate the .5-.55 problem when sweeping
-        if (-1e-12 < z and z < 1e-12):
-            warn('Warning: getB Position directly on current line', RuntimeWarning)
-            return array([NaN, NaN, NaN])
-
-    deltaP = sqrt((r+r0)**2+z**2)
-    deltaM = sqrt((r-r0)**2+z**2)
-    kappa = deltaP**2/deltaM**2
-    kappaBar = 1-kappa
-
-    # avoid discontinuity at r=0
-    if (-1e-12 < r and r < 1e-12):
-        Br = 0.
-    else:
-        Br = -2*1e-4*i0*(z/r/deltaM)*(ellipticK(kappaBar) -
-                                      (2-kappaBar)/(2-2*kappaBar)*ellipticE(kappaBar))
-    Bz = -2*1e-4*i0*(1/deltaM)*(-ellipticK(kappaBar)+(2-kappaBar -
-                                                      4*(r0/deltaM)**2)/(2-2*kappaBar)*ellipticE(kappaBar))
-
-    # transfer to cartesian coordinates
-    Bcy = array([Br, 0., Bz])*1000.  # mT output
-    T_Cy_to_Kart = array(
-        [[cos(phi), -sin(phi), 0], [sin(phi), cos(phi), 0], [0, 0, 1]])
-    Bkart = T_Cy_to_Kart.dot(Bcy)
-
-    return Bkart
diff --git a/magpylib/_lib/fields/Current_CircularLoop_vector.py b/magpylib/_lib/fields/Current_CircularLoop_vector.py
deleted file mode 100644
index dcd6364b8..000000000
--- a/magpylib/_lib/fields/Current_CircularLoop_vector.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import sqrt, arctan2, empty, pi
-from magpylib._lib.mathLib_vector import ellipticKV, ellipticEV, angleAxisRotationV_priv
-import numpy as np
-
-
-# %% CIRCULAR CURRENT LOOP
-# Describes the magnetic field of a circular current loop that lies parallel to
-#    the x-y plane. The loop has the radius r0 and carries the current i0, the
-#    center of the current loop lies at posCL
-
-# i0   : float  [A]     current in the loop
-# d0   : float  [mm]    diameter of the current loop
-# posCL: arr3  [mm]     Position of the center of the current loop
-
-# VECTORIZATION
-
-def Bfield_CircularCurrentLoopV(I0, D, POS):
-
-    R = D/2  #radius
-
-    N = len(D)  # vector size
-
-    X,Y,Z = POS[:,0],POS[:,1],POS[:,2]
-
-    RR, PHI = sqrt(X**2+Y**2), arctan2(Y, X)      # cylindrical coordinates
-
-
-    deltaP = sqrt((RR+R)**2+Z**2)
-    deltaM = sqrt((RR-R)**2+Z**2)
-    kappa = deltaP**2/deltaM**2
-    kappaBar = 1-kappa
-
-    # allocate solution vector
-    field_R = empty([N])
-
-    # avoid discontinuity of Br on z-axis
-    maskRR0 = RR == np.zeros([N])
-    field_R[maskRR0] = 0
-    # R-component computation
-    notM = np.invert(maskRR0)
-    field_R[notM] = -2*1e-4*(Z[notM]/RR[notM]/deltaM[notM])*(ellipticKV(kappaBar[notM]) -
-                        (2-kappaBar[notM])/(2-2*kappaBar[notM])*ellipticEV(kappaBar[notM]))
-    
-    # Z-component computation
-    field_Z = -2*1e-4*(1/deltaM)*(-ellipticKV(kappaBar)+(2-kappaBar -
-                                                      4*(R/deltaM)**2)/(2-2*kappaBar)*ellipticEV(kappaBar))
-
-    # transformation to cartesian coordinates
-    Bcy = np.array([field_R,np.zeros(N),field_Z]).T
-    AX = np.zeros([N,3])
-    AX[:,2] = 1
-    Bkart = angleAxisRotationV_priv(PHI/pi*180,AX,Bcy)
-
-    return (Bkart.T * I0).T * 1000 # to mT
-
diff --git a/magpylib/_lib/fields/Current_Line_vector.py b/magpylib/_lib/fields/Current_Line_vector.py
deleted file mode 100644
index cad27aa0d..000000000
--- a/magpylib/_lib/fields/Current_Line_vector.py
+++ /dev/null
@@ -1,203 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-import numpy as np
-from numpy.linalg import norm
-
-# %% CURRENT LINE
-# Describes the magnetic field of a line current. The line is given by a set of
-#   data points and the field is the superopsition of the fields of all linear
-#   segments.
-
-# i0   : float       [A]       current lowing from the first point to the last point
-# pos  : arr3 float  [mm]      Position of observer
-# possis: list or arr of arr3floats [mm]    Positions that define the line
-
-# source: http://www.phys.uri.edu/gerhard/PHY204/tsl216.pdf
-# FieldOfLineCurrent
-
-# VECTORIZED VERSION
-# here we only use vectorized code as this function will primarily be
-#   used to call on multiple line segments. The vectorized code was
-#   developed based on the depreciated version below.
-
-def Bfield_LineSegmentV(p0, p1, p2, I0):
-    ''' private
-    base function determines the fields of given line segments
-    p0 = observer position
-    p1->p2 = current flows from vertex p1 to vertex p2
-    I0 = current in [A]
-    '''
-
-    N = len(p0)
-    fields = np.zeros((N,3)) # default values for mask0 and mask1
-
-    # Check for zero-length segment
-    mask0 = np.all(p1==p2,axis=1)
-
-    # projection of p0 onto line p1-p2
-    nm0 = np.invert(mask0)
-    p1p2 = (p1[nm0]-p2[nm0])
-    p4 = p1[nm0]+(p1p2.T*np.sum((p0[nm0]-p1[nm0])*p1p2,axis=1)/np.sum(p1p2**2,axis=1)).T
-
-    # determine anchorrect normal vector to surface spanned by triangle
-    cross0 = np.cross(-p1p2, p0[nm0]-p4)
-    norm_cross0 = norm(cross0,axis=1)
-
-    # on-line cases (include when position is on current path)
-    mask1 = (norm_cross0 == 0)
-
-    # normal cases
-    nm1 = np.invert(mask1)
-    eB = (cross0[nm1].T/norm_cross0[nm1]) #field direction
-
-    # not mask0 and not mask1
-    NM = np.copy(nm0)
-    NM[NM==True] = nm1
-
-    norm_04 = norm(p0[NM] -p4[nm1],axis=1)
-    norm_01 = norm(p0[NM] -p1[NM],axis=1)
-    norm_02 = norm(p0[NM] -p2[NM],axis=1)
-    norm_12 = norm(p1[NM] -p2[NM],axis=1)
-    norm_41 = norm(p4[nm1]-p1[NM],axis=1)
-    norm_42 = norm(p4[nm1]-p2[NM],axis=1)
-
-    sinTh1 = norm_41/norm_01
-    sinTh2 = norm_42/norm_02
-
-    deltaSin = np.empty((N))[NM]
-
-    # determine how p1,p2,p4 are sorted on the line (to get sinTH signs)
-    # both points below
-    mask2 = ((norm_41>norm_12) * (norm_41>norm_42))
-    deltaSin[mask2] = abs(sinTh1[mask2]-sinTh2[mask2])
-    # both points above
-    mask3 = ((norm_42>norm_12) * (norm_42>norm_41))
-    deltaSin[mask3] = abs(sinTh2[mask3]-sinTh1[mask3])
-    # one above one below or one equals p4
-    mask4 = np.invert(mask2)*np.invert(mask3)
-    deltaSin[mask4] = abs(sinTh1[mask4]+sinTh2[mask4])
-
-    # missing 10**-6 from m->mm conversion #T->mT conversion
-    fields[NM] = (I0[NM]*deltaSin/norm_04*eB).T/10
-
-    return fields
-
-
-
-def Bfield_CurrentLineV(VERT,i0,poso):
-    ''' private
-    determine total field from a multi-segment line current
-    '''
-
-    N = len(VERT)-1
-    P0 = np.tile(poso,(N,1))
-    P1 = VERT[:-1]
-    P2 = VERT[1:]
-    I0 = np.ones((N))*i0
-
-    Bv = Bfield_LineSegmentV(P0,P1,P2,I0)
-
-    return np.sum(Bv,axis=0)
-
-
-def Bfield_CurrentLineVV(VERT,i0,POS):
-    ''' private
-    determine total field from a multi-segment line current for multiple positions
-    '''
-    N = len(VERT)-1
-    M = len(POS)
-
-    P1 = VERT[:-1]
-    P2 = VERT[1:]
-    P1 = np.tile(P1,(M,1))
-    P2 = np.tile(P2,(M,1))
-    
-    P0 = np.tile(POS,(1,N)).reshape((N*M,3))
-
-    I0 = np.ones(N*M)*i0
-
-    Bv = Bfield_LineSegmentV(P0,P1,P2,I0)
-    Bv = Bv.reshape((M,N,3))
-    Bv = np.sum(Bv,axis=1)
-    return Bv
-
-
-
-
-''' DEPRECHIATED VERSION (non-vectorized)
-
-# observer at p0
-# current I0 flows in straight line from p1 to p2
-def Bfield_LineSegment(p0, p1, p2, I0):
-    # must receive FLOAT only !!!!
-    # Check for zero-length segment
-    if all(p1==p2):
-        warn("Zero-length segment line detected in vertices list,"
-             "returning [0,0,0]", RuntimeWarning)
-        return array([0, 0, 0])
-
-    # projection of p0 onto line p1-p2
-    p4 = p1+(p1-p2)*fastSum3D((p0-p1)*(p1-p2))/fastSum3D((p1-p2)*(p1-p2))
-
-    # determine anchorrect normal vector to surface spanned by triangle
-    cross0 = fastCross3D(p2-p1, p0-p4)
-    norm_cross0 = fastNorm3D(cross0)
-    if norm_cross0 != 0.:
-        eB = cross0/norm_cross0
-    else:  # on line case (p0,p1,p2) do not span a triangle
-        norm_12 = fastNorm3D(p1-p2)
-        norm_42 = fastNorm3D(p4-p2)
-        norm_41 = fastNorm3D(p4-p1)
-
-        if (norm_41 <= norm_12 and norm_42 <= norm_12):  # in-between the two points
-            warn('Warning: getB Position directly on current line', RuntimeWarning)
-            return array([NaN, NaN, NaN])
-        else:
-            return array([0, 0, 0])
-
-    # determine sinTHs and R
-    norm_04 = fastNorm3D(p0-p4)  # =R
-    norm_01 = fastNorm3D(p0-p1)
-    norm_02 = fastNorm3D(p0-p2)
-    norm_12 = fastNorm3D(p1-p2)
-    norm_41 = fastNorm3D(p4-p1)
-    norm_42 = fastNorm3D(p4-p2)
-
-    sinTh1 = norm_41/norm_01
-    sinTh2 = norm_42/norm_02
-
-    # determine how p1,p2,p4 are sorted on the line (to get sinTH signs)
-    if norm_41 > norm_12 and norm_41 > norm_42:  # both points below
-        deltaSin = abs(sinTh1-sinTh2)
-    elif norm_42 > norm_12 and norm_42 > norm_41:  # both points above
-        deltaSin = abs(sinTh2-sinTh1)
-    else:  # one above one below or one equals p4
-        deltaSin = abs(sinTh1+sinTh2)
-
-    # missing 10**-6 from m->mm conversion #T->mT conversion
-    B = I0*deltaSin/norm_04*eB/10
-
-    return B
-'''
\ No newline at end of file
diff --git a/magpylib/_lib/fields/Moment_Dipole.py b/magpylib/_lib/fields/Moment_Dipole.py
deleted file mode 100644
index f07ac5065..000000000
--- a/magpylib/_lib/fields/Moment_Dipole.py
+++ /dev/null
@@ -1,49 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from magpylib._lib.mathLib import fastSum3D
-from numpy import pi, array, NaN
-from warnings import warn
-
-# %% DIPOLE field
-
-# describes the field of a dipole positioned at posM and pointing into the direction of M
-
-# M    : arr3  [mT]    Magnetic moment, M = µ0*m
-# pos  : arr3  [mm]    Position of observer
-# posM : arr3  [mm]    Position of dipole moment
-
-# |M| corresponds to the magnetic moment of a cube with remanence Br and Volume V such that
-#       |M| [mT*mm^3]  =  Br[mT] * V[mm^3]
-
-def Bfield_Dipole(M, pos):
-    R = pos
-    rr = fastSum3D(R*R)
-    mr = fastSum3D(M*R)
-
-    if rr == 0:
-        warn('Warning: getB Position directly at moment position', RuntimeWarning)
-        return array([NaN, NaN, NaN])
-
-    return (3*R*mr-M*rr)/rr**(5/2)/(4*pi)
diff --git a/magpylib/_lib/fields/Moment_Dipole_vector.py b/magpylib/_lib/fields/Moment_Dipole_vector.py
deleted file mode 100644
index 3bc4dd31e..000000000
--- a/magpylib/_lib/fields/Moment_Dipole_vector.py
+++ /dev/null
@@ -1,49 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-from numpy import pi
-import numpy as np
-
-# %% DIPOLE field
-
-# describes the field of a dipole positioned at posM and pointing into the direction of M
-
-# M    : arr3  [mT]    Magnetic moment, M = µ0*m
-# pos  : arr3  [mm]    Position of observer
-# posM : arr3  [mm]    Position of dipole moment
-
-# |M| corresponds to the magnetic moment of a cube with remanence Br and Volume V such that
-#       |M| [mT*mm^3]  =  Br[mT] * V[mm^3]
-
-# VECTORIZED VERSION
-
-def Bfield_DipoleV(MOM, POS):
-    R = POS
-    rr = np.sum(POS**2,axis=1)
-    mr = np.sum(MOM*R,axis=1)
-
-    field = (3*R.T*mr-MOM.T*rr)/rr**(5/2)/(4*pi)
-
-    return field.T
-    
\ No newline at end of file
diff --git a/magpylib/_lib/fields/PM_Box.py b/magpylib/_lib/fields/PM_Box.py
deleted file mode 100644
index b5c5f99b3..000000000
--- a/magpylib/_lib/fields/PM_Box.py
+++ /dev/null
@@ -1,179 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-
-from numpy import pi, sign, sqrt, log, array, arctan, NaN
-from warnings import warn
-
-# Describes the magnetic field of a cuboid magnet with sides parallel to its native
-#   cartesian coordinates. The dimension is 2a x 2b x 2c and the magnetization
-#   is given by MAG. The center of the box is positioned at posM.
-
-# MAG : arr3   [mT/mm³]     Magnetization per unit volume, MAG = mu0*mag = remanence field
-# pos  : arr3  [mm]        Position of observer
-# dim  : arr3  [mm]        dim = [a,b,c], Magnet dimension = A x B x C
-
-# basic functions required to calculate the cuboid's fields
-
-# calculating the field
-# returns arr3 of field vector in [mT], input in [mT] and [mm]
-def Bfield_Box(MAG, pos, dim):
-
-    MAGx, MAGy, MAGz = MAG/4/pi
-    x, y, z = pos
-    a, b, c = dim/2
-
-    xma, xpa = x-a, x+a
-    ymb, ypb = y-b, y+b
-    zmc, zpc = z-c, z+c
-
-    xma2, xpa2 = xma**2, xpa**2
-    ymb2, ypb2 = ymb**2, ypb**2
-    zmc2, zpc2 = zmc**2, zpc**2
-
-    MMM = sqrt(xma2 + ymb2 + zmc2)
-    PMP = sqrt(xpa2 + ymb2 + zpc2)
-    PMM = sqrt(xpa2 + ymb2 + zmc2)
-    MMP = sqrt(xma2 + ymb2 + zpc2)
-    MPM = sqrt(xma2 + ypb2 + zmc2)
-    PPP = sqrt(xpa2 + ypb2 + zpc2)
-    PPM = sqrt(xpa2 + ypb2 + zmc2)
-    MPP = sqrt(xma2 + ypb2 + zpc2)
-
-    # special cases:
-    #   0. volume cases      no quantities are zero
-    #   1. on surfaces:      one quantity is zero
-    #   2. on edge lines:    two quantities are zero
-    #   3. on corners:       three quantities are zero
-    CASE = 0
-    for case in array([xma, xpa, ymb, ypb, zmc, zpc]):
-        if (case < 1e-15 and -1e-15 < case):
-            CASE += 1
-    # rounding is required to catch numerical problem cases like .5-.55=.05000000000000001
-    #   which then result in 'normal' cases but the square eliminates the small digits
-
-    # case 1(on magnet): catch on magnet surface cases------------------------
-    if CASE == 1:
-        if abs(x) <= a:
-            if abs(y) <= b:
-                if abs(z) <= c:
-                    warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-                    return array([NaN, NaN, NaN])
-
-    # cases 2 & 3 (edgelines edges and corners) ------------------------------
-    if CASE > 1:
-
-        # on corner and on edge cases - no phys solution
-        # directly on magnet edge or corner - log singularity here as result of unphysical model
-        if all([abs(x) <= a, abs(y) <= b, abs(z) <= c]):
-            warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-            return array([NaN, NaN, NaN])
-
-        # problematic edgeline cases (here some specific LOG quantites become problematic and are obtained from a mirror symmetry)
-        caseA = (xma < 0 and xpa < 0)
-        caseB = (ymb > 0 and ypb > 0)
-        caseC = (zmc > 0 and zpc > 0)
-
-        if caseA:
-            xma, xpa = -xma, -xpa
-        elif caseB:
-            ymb, ypb = -ymb, -ypb
-        elif caseC:
-            zmc, zpc = -zmc, -zpc
-
-        LOGx = log(((xma+MMM)*(xpa+PPM)*(xpa+PMP)*(xma+MPP)) /
-                   ((xpa+PMM)*(xma+MPM)*(xma+MMP)*(xpa+PPP)))
-        LOGy = log(((-ymb+MMM)*(-ypb+PPM)*(-ymb+PMP)*(-ypb+MPP)) /
-                   ((-ymb+PMM)*(-ypb+MPM)*(ymb-MMP)*(ypb-PPP)))
-        LOGz = log(((-zmc+MMM)*(-zmc+PPM)*(-zpc+PMP)*(-zpc+MPP)) /
-                   ((-zmc+PMM)*(zmc-MPM)*(-zpc+MMP)*(zpc-PPP)))
-
-        if caseA:
-            LOGx, xma, xpa = -LOGx, -xma, -xpa
-        elif caseB:
-            LOGy, ymb, ypb = -LOGy, -ymb, -ypb
-        elif caseC:
-            LOGz, zmc, zpc = -LOGz, -zmc, -zpc
-
-    # case 0 and 1(off magnet): (most cases) -----------------------------------------------
-    else:
-        # these quantities have positive definite denominators in all cases 0 and 1
-        LOGx = log(((xma+MMM)*(xpa+PPM)*(xpa+PMP)*(xma+MPP)) /
-                   ((xpa+PMM)*(xma+MPM)*(xma+MMP)*(xpa+PPP)))
-        LOGy = log(((-ymb+MMM)*(-ypb+PPM)*(-ymb+PMP)*(-ypb+MPP)) /
-                   ((-ymb+PMM)*(-ypb+MPM)*(ymb-MMP)*(ypb-PPP)))
-        LOGz = log(((-zmc+MMM)*(-zmc+PPM)*(-zpc+PMP)*(-zpc+MPP)) /
-                   ((-zmc+PMM)*(zmc-MPM)*(-zpc+MMP)*(zpc-PPP)))
-
-    # calculate unproblematic field components
-    BxY = MAGy*LOGz
-    BxZ = MAGz*LOGy
-    ByX = MAGx*LOGz
-    ByZ = -MAGz*LOGx
-    BzX = MAGx*LOGy
-    BzY = -MAGy*LOGx
-
-    # calculate problematic field components (limit to surfaces)
-    if xma == 0:
-        BxX = MAGx*(-arctan((ymb*zmc)/(xpa*PMM)) + arctan((ypb*zmc)/(xpa*PPM)) + arctan((ymb*zpc) /
-                                                                                        (xpa*PMP)) - arctan((ypb*zpc)/(xpa*PPP)) + (pi/2)*(sign(ymb)-sign(ypb))*(sign(zmc)-sign(zpc)))
-    elif xpa == 0:
-        BxX = -MAGx*(-arctan((ymb*zmc)/(xma*MMM)) + arctan((ypb*zmc)/(xma*MPM)) + arctan((ymb*zpc) /
-                                                                                         (xma*MMP)) - arctan((ypb*zpc)/(xma*MPP)) + (pi/2)*(sign(ymb)-sign(ypb))*(sign(zmc)-sign(zpc)))
-    else:
-        BxX = MAGx*(arctan((ymb*zmc)/(xma*MMM)) - arctan((ymb*zmc)/(xpa*PMM)) - arctan((ypb*zmc)/(xma*MPM)) + arctan((ypb*zmc)/(xpa*PPM))
-                    - arctan((ymb*zpc)/(xma*MMP)) + arctan((ymb*zpc)/(xpa*PMP)) + arctan((ypb*zpc)/(xma*MPP)) - arctan((ypb*zpc)/(xpa*PPP)))
-
-    if ymb == 0:
-        ByY = MAGy*(-arctan((xma*zmc)/(MPM*ypb)) + arctan((xpa*zmc)/(PPM*ypb)) + arctan((xma*zpc) /
-                                                                                        (MPP*ypb)) - arctan((xpa*zpc)/(PPP*ypb)) + (pi/2)*(sign(xma)-sign(xpa))*(sign(zmc)-sign(zpc)))
-    elif ypb == 0:
-        ByY = -MAGy*(-arctan((xma*zmc)/(MMM*ymb)) + arctan((xpa*zmc)/(PMM*ymb)) + arctan((xma*zpc) /
-                                                                                         (MMP*ymb)) - arctan((xpa*zpc)/(PMP*ymb)) + (pi/2)*(sign(xma)-sign(xpa))*(sign(zmc)-sign(zpc)))
-    else:
-        ByY = MAGy*(arctan((xma*zmc)/(ymb*MMM)) - arctan((xpa*zmc)/(ymb*PMM)) - arctan((xma*zmc)/(ypb*MPM)) + arctan((xpa*zmc)/(ypb*PPM))
-                    - arctan((xma*zpc)/(ymb*MMP)) + arctan((xpa*zpc)/(ymb*PMP)) + arctan((xma*zpc)/(ypb*MPP)) - arctan((xpa*zpc)/(ypb*PPP)))
-
-    if zmc == 0:
-        BzZ = MAGz*(-arctan((xma*ymb)/(MMP*zpc)) + arctan((xpa*ymb)/(PMP*zpc)) + arctan((xma*ypb) /
-                                                                                        (MPP*zpc)) - arctan((xpa*ypb)/(PPP*zpc)) + (pi/2)*(sign(xma)-sign(xpa))*(sign(ymb)-sign(ypb)))
-    elif zpc == 0:
-        BzZ = -MAGz*(-arctan((xma*ymb)/(MMM*zmc)) + arctan((xpa*ymb)/(PMM*zmc)) + arctan((xma*ypb) /
-                                                                                         (MPM*zmc)) - arctan((xpa*ypb)/(PPM*zmc)) + (pi/2)*(sign(xma)-sign(xpa))*(sign(ymb)-sign(ypb)))
-    else:
-        BzZ = MAGz*(arctan((xma*ymb)/(zmc*MMM)) - arctan((xpa*ymb)/(zmc*PMM)) - arctan((xma*ypb)/(zmc*MPM)) + arctan((xpa*ypb)/(zmc*PPM))
-                    - arctan((xma*ymb)/(zpc*MMP)) + arctan((xpa*ymb)/(zpc*PMP)) + arctan((xma*ypb)/(zpc*MPP)) - arctan((xpa*ypb)/(zpc*PPP)))
-
-    Bxtot = (BxX+BxY+BxZ)
-    Bytot = (ByX+ByY+ByZ)
-    Bztot = (BzX+BzY+BzZ)
-    field = array([Bxtot, Bytot, Bztot])
-
-    # add M when inside the box to make B out of H-------------
-    if abs(x) < a:
-        if abs(y) < b:
-            if abs(z) < c:
-                field += MAG
-
-    return field
diff --git a/magpylib/_lib/fields/PM_Box_vector.py b/magpylib/_lib/fields/PM_Box_vector.py
deleted file mode 100644
index 12c32f856..000000000
--- a/magpylib/_lib/fields/PM_Box_vector.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-
-from numpy import arctan2, transpose, sqrt, log, pi, array
-from warnings import warn
-
-
-def Bfield_BoxV(MAG, POS, DIM):
-    #magnetic field of the BOX - vectorized
-    # no special cases
-    # no addition of MAG on inside
-
-    MAGx,MAGy,MAGz = MAG[:,0]/4/pi, MAG[:,1]/4/pi, MAG[:,2]/4/pi
-    x,y,z = POS[:,0],POS[:,1],POS[:,2]
-    a,b,c = DIM[:,0]/2,DIM[:,1]/2,DIM[:,2]/2
-
-    xma, xpa = x-a, x+a
-    ymb, ypb = y-b, y+b
-    zmc, zpc = z-c, z+c
-
-    xma2, xpa2 = xma**2, xpa**2
-    ymb2, ypb2 = ymb**2, ypb**2
-    zmc2, zpc2 = zmc**2, zpc**2
-
-    MMM = sqrt(xma2 + ymb2 + zmc2)
-    PMP = sqrt(xpa2 + ymb2 + zpc2)
-    PMM = sqrt(xpa2 + ymb2 + zmc2)
-    MMP = sqrt(xma2 + ymb2 + zpc2)
-    MPM = sqrt(xma2 + ypb2 + zmc2)
-    PPP = sqrt(xpa2 + ypb2 + zpc2)
-    PPM = sqrt(xpa2 + ypb2 + zmc2)
-    MPP = sqrt(xma2 + ypb2 + zpc2)
-    
-    # these quantities have positive definite denominators in all cases 0 and 1
-    LOGx = log(((xma+MMM)*(xpa+PPM)*(xpa+PMP)*(xma+MPP)) /
-                ((xpa+PMM)*(xma+MPM)*(xma+MMP)*(xpa+PPP)))
-    LOGy = log(((-ymb+MMM)*(-ypb+PPM)*(-ymb+PMP)*(-ypb+MPP)) /
-                ((-ymb+PMM)*(-ypb+MPM)*(ymb-MMP)*(ypb-PPP)))
-    LOGz = log(((-zmc+MMM)*(-zmc+PPM)*(-zpc+PMP)*(-zpc+MPP)) /
-                ((-zmc+PMM)*(zmc-MPM)*(-zpc+MMP)*(zpc-PPP)))
-
-    # calculate unproblematic field components
-    BxY = MAGy*LOGz
-    BxZ = MAGz*LOGy
-    ByX = MAGx*LOGz
-    ByZ = -MAGz*LOGx
-    BzX = MAGx*LOGy
-    BzY = -MAGy*LOGx
-
-    BxX = MAGx*(arctan2((ymb*zmc),(xma*MMM)) - arctan2((ymb*zmc),(xpa*PMM)) - arctan2((ypb*zmc),(xma*MPM)) + arctan2((ypb*zmc),(xpa*PPM))
-                    - arctan2((ymb*zpc),(xma*MMP)) + arctan2((ymb*zpc),(xpa*PMP)) + arctan2((ypb*zpc),(xma*MPP)) - arctan2((ypb*zpc),(xpa*PPP)))
-
-    ByY = MAGy*(arctan2((xma*zmc),(ymb*MMM)) - arctan2((xpa*zmc),(ymb*PMM)) - arctan2((xma*zmc),(ypb*MPM)) + arctan2((xpa*zmc),(ypb*PPM))
-                    - arctan2((xma*zpc),(ymb*MMP)) + arctan2((xpa*zpc),(ymb*PMP)) + arctan2((xma*zpc),(ypb*MPP)) - arctan2((xpa*zpc),(ypb*PPP)))
-
-    BzZ = MAGz*(arctan2((xma*ymb),(zmc*MMM)) - arctan2((xpa*ymb),(zmc*PMM)) - arctan2((xma*ypb),(zmc*MPM)) + arctan2((xpa*ypb),(zmc*PPM))
-                    - arctan2((xma*ymb),(zpc*MMP)) + arctan2((xpa*ymb),(zpc*PMP)) + arctan2((xma*ypb),(zpc*MPP)) - arctan2((xpa*ypb),(zpc*PPP)))
-
-    Bxtot = (BxX+BxY+BxZ)
-    Bytot = (ByX+ByY+ByZ)
-    Bztot = (BzX+BzY+BzZ)
-    field = array([Bxtot, Bytot, Bztot])
-
-    return transpose(field)
\ No newline at end of file
diff --git a/magpylib/_lib/fields/PM_Cylinder.py b/magpylib/_lib/fields/PM_Cylinder.py
deleted file mode 100644
index 4c03b4067..000000000
--- a/magpylib/_lib/fields/PM_Cylinder.py
+++ /dev/null
@@ -1,171 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-# MAGNETIC FIELD CALCULATION OF CYLINDER IN CANONICAL BASIS
-
-
-# %% IMPORTS
-from numpy import pi, sqrt, array, arctan2, cos, sin, arange, NaN
-from magpylib._lib.mathLib import getPhi, elliptic
-from warnings import warn
-
-# %% Cylinder Field Calculation
-# Describes the magnetic field of a cylinder with circular top and bottom and
-#   arbitrary magnetization given by MAG. The axis of the cylinder is parallel
-#   to the z-axis. The dimension are given by the radius r and the height h.
-#   The center of the cylinder is positioned at the origin.
-
-# basic functions required to calculate the diametral contributions
-def Sphi(n, Nphi):
-    if n == 0:
-        return 1./3
-    elif n == Nphi:
-        return 1./3
-    elif n % 2 == 1:
-        return 4./3
-    elif n % 2 == 0:
-        return 2./3
-
-
-# MAG  : arr3  [mT/mm³]    Magnetization vector (per unit volume)
-# pos  : arr3  [mm]        Position of observer
-# dim  : arr3  [mm]        dim = [d,h], Magnet diameter r and height h
-
-# this calculation returns the B-field from the statrt as it is based on a current equivalent
-def Bfield_Cylinder(MAG, pos, dim, Nphi0):  # returns arr3
-
-    D, H = dim                # magnet dimensions
-    R = D/2
-
-    x, y, z = pos       # relative position
-    r, phi = sqrt(x**2+y**2), getPhi(x, y)      # cylindrical coordinates
-
-    # Mag part in z-direction
-    B0z = MAG[2]  # z-part of magnetization
-    zP, zM = z+H/2., z-H/2.   # some important quantitites
-    Rpr, Rmr = R+r, R-r
-
-    # special cases:
-    #   0. volume cases      no quantities are zero
-    #   1. on surfaces:      one quantity is zero
-    #   2. on edge lines:    two quantities are zero
-    CASE = 0
-    for case in array([Rmr, zP, zM]):
-        if (case < 1e-15 and -1e-15 < case):
-            CASE += 1
-    # rounding is required to catch numerical problem cases like .5-.55=.05000000000000001
-    #   which then result in 'normal' cases but the square eliminates the small digits
-
-    # edge cases ----------------------------------------------
-    if CASE == 2:
-        warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-        return array([NaN, NaN, NaN])
-
-    # on-magnet surface cases----------------------------------
-    elif CASE == 1:
-        if Rmr == 0:  # on cylinder surface
-            if abs(z) < H/2:  # directly on magnet
-                warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-                return array([NaN, NaN, NaN])
-        else:  # on top or bottom surface
-            if Rmr > 0:  # directly on magnet
-                warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-                return array([NaN, NaN, NaN])
-
-    # Volume Cases and off-magnet surface cases----------------
-
-    SQ1 = sqrt(zP**2+Rpr**2)
-    SQ2 = sqrt(zM**2+Rpr**2)
-
-    alphP = R/SQ1
-    alphM = R/SQ2
-    betP = zP/SQ1
-    betM = zM/SQ2
-    kP = sqrt((zP**2+Rmr**2)/(zP**2+Rpr**2))
-    kM = sqrt((zM**2+Rmr**2)/(zM**2+Rpr**2))
-    gamma = Rmr/Rpr
-
-    # radial field
-    Br_Z = B0z*(alphP*elliptic(kP, 1, 1, -1)-alphM*elliptic(kM, 1, 1, -1))/pi
-    Bx_Z = Br_Z*cos(phi)
-    By_Z = Br_Z*sin(phi)
-
-    # axial field
-    Bz_Z = B0z*R/(Rpr)*(betP*elliptic(kP, gamma**2, 1, gamma) -
-                        betM*elliptic(kM, gamma**2, 1, gamma))/pi
-
-    Bfield = array([Bx_Z, By_Z, Bz_Z])  # contribution from axial magnetization
-
-    # Mag part in xy-direction requires a numeical algorithm
-    B0xy = sqrt(MAG[0]**2+MAG[1]**2)  # xy-magnetization amplitude
-    if B0xy > 0:
-
-        tetta = arctan2(MAG[1],MAG[0])
-        gamma = arctan2(y,x)
-        phi = gamma-tetta
-
-        phi0s = 2*pi/Nphi0  # discretization
-
-        rR2 = 2*r*R
-        r2pR2 = r**2+R**2
-
-        def I1x(phi0, z0):
-            if r2pR2-rR2*cos(phi-phi0) == 0:
-                return -1/2/(z-z0)**2
-            else:
-                G = 1/sqrt(r2pR2-rR2*cos(phi-phi0)+(z-z0)**2)
-                return (z-z0)*G/(r2pR2-rR2*cos(phi-phi0))
-
-        #USE VECTORIZED CODE FOR THE SUMMATION !!!!
-
-        # radial component
-        Br_XY = B0xy*R/2/Nphi0*sum([
-                Sphi(n, Nphi0)*cos(phi0s*n) * (r-R*cos(phi-phi0s*n)) * (I1x(phi0s*n,-H/2)-I1x(phi0s*n,H/2))
-            for n in arange(Nphi0+1)])
-
-        # angular component
-        Bphi_XY = B0xy*R**2/2/Nphi0*sum([
-                Sphi(n, Nphi0)*cos(phi0s*n) * sin(phi-phi0s*n) * (I1x(phi0s*n,-H/2)-I1x(phi0s*n,H/2))
-            for n in arange(Nphi0+1)])
-        
-        # axial component
-        Bz_XY = B0xy*R/2/Nphi0*sum([
-            sum([
-                (-1)**k*Sphi(n, Nphi0)*cos(phi0s*n) /
-                sqrt(r2pR2-rR2*cos(phi-phi0s*n)+(z-z0)**2)
-                for z0, k in zip([-H/2, H/2], [1, 2])])
-            for n in arange(Nphi0+1)])
-
-        # translate r,phi to x,y coordinates
-        phi = gamma
-        Bx_XY = Br_XY*cos(phi)-Bphi_XY*sin(phi)
-        By_XY = Br_XY*sin(phi)+Bphi_XY*cos(phi)
-
-        Bfield = Bfield + array([Bx_XY, By_XY, Bz_XY])
-
-        # add M if inside the cylinder to make B out of H
-        if r < R and abs(z) < H/2:
-            Bfield += array([MAG[0], MAG[1], 0])
-
-    return Bfield
diff --git a/magpylib/_lib/fields/PM_Cylinder_vector.py b/magpylib/_lib/fields/PM_Cylinder_vector.py
deleted file mode 100644
index 67ed5e55e..000000000
--- a/magpylib/_lib/fields/PM_Cylinder_vector.py
+++ /dev/null
@@ -1,174 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-# MAGNETIC FIELD CALCULATION OF CYLINDER IN CANONICAL BASIS
-
-# VECTORIZED VERSION
-
-# %% IMPORTS
-from numpy import pi, sqrt, array, arctan2, cos, sin
-import numpy as np
-from magpylib._lib.mathLib_vector import ellipticV
-
-# Describes the magnetic field of a cylinder with circular top and bottom and
-#   arbitrary magnetization given by MAG. The axis of the cylinder is parallel
-#   to the z-axis. The dimension are given by the radius r and the height h.
-#   The center of the cylinder is positioned at the origin.
-
-# basic functions required to calculate the diametral contributions
-
-
-
-# MAG  : arr3  [mT/mm³]    Magnetization vector (per unit volume)
-# pos  : arr3  [mm]        Position of observer
-# dim  : arr3  [mm]        dim = [d,h], Magnet diameter r and height h
-
-# this calculation returns the B-field from the statrt as it is based on a current equivalent
-def Bfield_CylinderV(MAG, POS, DIM, Nphi0):  # returns arr3
-
-    D = DIM[:,0]                # magnet dimensions
-    H = DIM[:,1]                # magnet dimensions
-    R = D/2
-
-    N = len(D)  # vector size
-
-    X,Y,Z = POS[:,0],POS[:,1],POS[:,2]
-
-    ### BEGIN AXIAL MAG CONTRIBUTION ###########################
-    RR, PHI = sqrt(X**2+Y**2), arctan2(Y, X)      # cylindrical coordinates
-    B0z = MAG[:,2]              # z-part of magnetization
-    
-    # some important quantitites
-    zP, zM = Z+H/2., Z-H/2.   
-    Rpr, Rmr = R+RR, R-RR
-
-    SQ1 = sqrt(zP**2+Rpr**2)
-    SQ2 = sqrt(zM**2+Rpr**2)
-
-    alphP = R/SQ1
-    alphM = R/SQ2
-    betP = zP/SQ1
-    betM = zM/SQ2
-    kP = sqrt((zP**2+Rmr**2)/(zP**2+Rpr**2))
-    kM = sqrt((zM**2+Rmr**2)/(zM**2+Rpr**2))
-    gamma = Rmr/Rpr
-
-    one = np.ones(N)
-
-    # radial field
-    Br_Z = B0z*(alphP*ellipticV(kP, one, one, -one)-alphM*ellipticV(kM, one, one, -one))/pi
-    Bx_Z = Br_Z*cos(PHI)
-    By_Z = Br_Z*sin(PHI)
-
-    # axial field
-    Bz_Z = B0z*R/(Rpr)*(betP*ellipticV(kP, gamma**2, one, gamma) -
-                        betM*ellipticV(kM, gamma**2, one, gamma))/pi
-
-    Bfield = np.c_[Bx_Z, By_Z, Bz_Z]  # contribution from axial magnetization
-
-    ### BEGIN TRANS MAG CONTRIBUTION ###########################
-
-    # Mag part in xy-direction requires a numerical algorithm
-    # mask0 selects only input values where xy-MAG is non-zero
-    B0xy = sqrt(MAG[:,0]**2+MAG[:,1]**2)
-    mask0 = (B0xy > 0.) # finite xy-magnetization mask    
-    N0 = np.sum(mask0)  #number of masked values
-
-    if N0 >= 1:
-        
-        tetta = arctan2(MAG[mask0,1],MAG[mask0,0])
-        gamma = arctan2(Y[mask0],X[mask0])
-        phi = gamma-tetta
-
-        phi0s = 2*pi/Nphi0  # discretization
-
-        # prepare masked arrays for use in algorithm
-
-        RR_m0 = RR[mask0]
-        R_m0 = R[mask0]        
-        rR2 = 2*R_m0*RR_m0
-        r2pR2 = R_m0**2+RR_m0**2
-        Z0_m0 = H[mask0]/2
-        Z_m0 = Z[mask0]
-        H_m0 = H[mask0]
-
-        Sphi = np.arange(Nphi0+1)
-        Sphi[Sphi%2==0] = 2.
-        Sphi[Sphi%2==1] = 4.
-        Sphi[0] = 1.
-        Sphi[-1] = 1.
-
-        SphiE = np.outer(Sphi,np.ones(N0))
-
-        I1xE = np.ones([Nphi0+1,N0])
-        phi0E = np.outer(np.arange(Nphi0+1),np.ones(N0))*phi0s
-
-        Z_m0E =  np.outer(np.ones(Nphi0+1),Z_m0)
-        Z0_m0E = np.outer(np.ones(Nphi0+1),Z0_m0)
-        phiE =   np.outer(np.ones(Nphi0+1),phi)
-        rR2E =   np.outer(np.ones(Nphi0+1),rR2)
-        r2pR2E = np.outer(np.ones(Nphi0+1),r2pR2)
-
-        # parts for multiple use
-        cosPhi = cos(phiE-phi0E)
-        
-        # calc R-PHI components
-        ma = (r2pR2E-rR2E*cosPhi == 0)
-        I1xE[ma] = - (1/2)/(Z_m0E[ma]+Z0_m0E[ma])**2 + (1/2)/(Z_m0E[ma]-Z0_m0E[ma])**2
-
-        nMa = np.logical_not(ma)
-        rrc = r2pR2E[nMa]-rR2E[nMa]*cosPhi[nMa]
-        Gm = 1/sqrt(rrc+(Z_m0E[nMa]+Z0_m0E[nMa])**2)
-        Gp = 1/sqrt(rrc+(Z_m0E[nMa]-Z0_m0E[nMa])**2)
-        I1xE[nMa] = ((Z_m0E+Z0_m0E)[nMa]*Gm-(Z_m0E-Z0_m0E)[nMa]*Gp)/rrc
-
-        Summand = SphiE/3.*cos(phi0E)*I1xE
-
-        Br_XY_m0   = B0xy[mask0]*R_m0/2/Nphi0*np.sum(Summand*(RR_m0-R_m0*cosPhi),axis=0)
-        Bphi_XY_m0 = B0xy[mask0]*R_m0**2/2/Nphi0*np.sum(Summand*sin(phiE-phi0E),axis=0)
-
-        # calc Z component
-        Gzm = 1./sqrt(r2pR2-rR2*cosPhi+(Z_m0E+H_m0/2)**2)
-        Gzp = 1./sqrt(r2pR2-rR2*cosPhi+(Z_m0E-H_m0/2)**2)
-        SummandZ = SphiE/3.*cos(phi0E)*(Gzp-Gzm)
-        Bz_XY_m0 = B0xy[mask0]*R_m0/2/Nphi0*np.sum(SummandZ,axis=0)
-
-        # translate r,phi to x,y coordinates
-        Bx_XY_m0 = Br_XY_m0*cos(gamma)-Bphi_XY_m0*sin(gamma)
-        By_XY_m0 = Br_XY_m0*sin(gamma)+Bphi_XY_m0*cos(gamma)
-
-        BfieldTrans = array([Bx_XY_m0, By_XY_m0, Bz_XY_m0]).T
-        
-        # add field from transversal mag to field from axial mag
-        Bfield[mask0] += BfieldTrans
-
-        # add M if inside the cylinder to make B out of H
-        mask0Inside = mask0 * (RR<R) * (abs(Z)<H/2)
-        Bfield[mask0Inside,:2] += MAG[mask0Inside,:2]
-    
-    ### END TRANS MAG CONTRIBUTION ###########################
-
-    return(Bfield)
-
-
diff --git a/magpylib/_lib/fields/PM_Sphere.py b/magpylib/_lib/fields/PM_Sphere.py
deleted file mode 100644
index 1645776e5..000000000
--- a/magpylib/_lib/fields/PM_Sphere.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-# MAGNETIC FIELD CALCULATION FOR SPHERICAL MAGNET IN CANONICAL BASIS
-
-
-# %% IMPORTS
-from magpylib._lib.mathLib import fastNorm3D, fastSum3D
-from numpy import array, NaN
-from warnings import warn
-
-# %% CALCULATION
-
-# The magnetic field of a spherical magnet with the center in the origin
-
-# MAG = magnetization vector     [mT]  - takes arr3
-# pos = position of the observer [mm]  - takes arr3
-# D = diameter of sphere         [mm]  - takes float
-
-
-def Bfield_Sphere(MAG, pos, D):  # returns array, takes (arr3, arr3, float)
-
-    radius = D/2
-    r = fastNorm3D(pos)
-
-    if r > radius:
-        return radius**3/3*(-MAG/r**3 + 3*fastSum3D(MAG*pos)*pos/r**5)
-    elif r == radius:
-        warn('Warning: getB Position directly on magnet surface', RuntimeWarning)
-        return array([NaN, NaN, NaN])
-    else:
-        return 2/3*MAG
diff --git a/magpylib/_lib/fields/PM_Sphere_vector.py b/magpylib/_lib/fields/PM_Sphere_vector.py
deleted file mode 100644
index f5b3dd944..000000000
--- a/magpylib/_lib/fields/PM_Sphere_vector.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-
-import numpy as np
-
-def Bfield_SphereV(MAG, pos, D):  # returns array, takes (arr3, arr3, float)
-
-    # this function is an extension of PM_Sphere
-
-    MAGT = np.transpose(MAG)
-    posT = np.transpose(pos)
-
-    radius = D/2
-    r = np.linalg.norm(pos,axis=1)
-
-    map1 = r>radius
-    map3 = r<radius
-
-    B1T = map1*radius**3/3*(-MAGT/r**3 + 3*np.sum(MAG*pos,axis=1)*posT/r**5)
-    B3T = map3*2/3*MAGT
-
-    B = np.transpose(B1T+B3T)
-
-    return B
\ No newline at end of file
diff --git a/magpylib/_lib/fields/__init__.py b/magpylib/_lib/fields/__init__.py
deleted file mode 100644
index e5908c536..000000000
--- a/magpylib/_lib/fields/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,  
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>. 
-# The acceptance of the conditions of the GNU Affero General Public License are 
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
\ No newline at end of file
diff --git a/magpylib/_lib/getBvector.py b/magpylib/_lib/getBvector.py
deleted file mode 100644
index 1756ec5aa..000000000
--- a/magpylib/_lib/getBvector.py
+++ /dev/null
@@ -1,240 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-from magpylib._lib.fields.PM_Box_vector import Bfield_BoxV
-from magpylib._lib.fields.PM_Cylinder_vector import Bfield_CylinderV
-from magpylib._lib.fields.PM_Sphere_vector import Bfield_SphereV
-from magpylib._lib.fields.Moment_Dipole_vector import Bfield_DipoleV
-from magpylib._lib.fields.Current_CircularLoop_vector import Bfield_CircularCurrentLoopV
-from magpylib._lib.fields.Current_Line_vector import Bfield_LineSegmentV
-from magpylib._lib.mathLib_vector import QconjV, QrotationV, QmultV, getRotQuatV, angleAxisRotationV_priv
-import numpy as np
-
-def getBv_magnet(type,MAG,DIM,POSm,POSo,ANG=[],AX=[],ANCH=[],Nphi0=50):
-    """
-    Calculate the field of magnets using vectorized performance code.
-
-    Parameters
-    ----------
-
-    type : string
-        source type either 'box', 'cylinder', 'sphere'.
-
-    MAG : Nx3 numpy array float [mT]
-        vector of N magnetizations.
-
-    DIM : NxY numpy array float [mm]
-        vector of N dimensions for each evaluation. The form of this vector depends
-        on the source type. Y=3/2/1 for box/cylinder/sphere
-
-    POSo : Nx3 numpy array float [mm]
-        vector of N positions of the observer.
-    
-    POSm : Nx3 numpy array float [mm]
-        vector of N initial source positions. These positions will be adjusted by
-        the given rotation parameters.
-
-    ANG=[] : length M list of size N numpy arrays float [deg]
-       Angles of M subsequent rotation operations applied to the N-sized POSm and
-       the implicit source orientation.
-    
-    AX=[] : length M list of Nx3 numpy arrays float []
-        Axis vectors of M subsequent rotation operations applied to the N-sized
-        POSm and the implicit source orientation.
-    
-    ANCH=[] : length M list of Nx3 numpy arrays float [mm]
-        Anchor positions of M subsequent rotations applied ot the N-sized POSm and
-        the implicit source orientation.
-    
-    Nphi0=50 : integer gives number of iterations used when calculating diametral
-        magnetized cylindrical magnets.
-    """
-
-    N = len(POSo)
-
-    Q = np.zeros([N,4])
-    Q[:,0] = 1              # init orientation
-    
-    Pm = POSm               #initial position
-
-    #apply rotation operations
-    for ANGLE,AXIS,ANCHOR in zip(ANG,AX,ANCH):
-        Q = QmultV(getRotQuatV(ANGLE,AXIS),Q)
-        Pm = angleAxisRotationV_priv(ANGLE,AXIS,Pm-ANCHOR)+ANCHOR
-
-    # transform into CS of source
-    POSrel = POSo-Pm        #relative position
-    Qc = QconjV(Q)          #orientierung
-    POSrot = QrotationV(Qc,POSrel)  #rotation der pos in das CS der Quelle
-    
-    # calculate field
-    if type == 'box':
-        Brot = Bfield_BoxV(MAG, POSrot, DIM)
-    elif type == 'cylinder':
-        Brot = Bfield_CylinderV(MAG, POSrot, DIM,Nphi0)
-    elif type == 'sphere':
-        Brot = Bfield_SphereV(MAG, POSrot, DIM)
-    else:
-        print('Bad type or WIP')
-        return 0
-    
-    # transform back
-    B = QrotationV(Q,Brot)  #rückrotation des feldes
-    
-    return B
-
-# -------------------------------------------------------------------------------
-# -------------------------------------------------------------------------------
-
-
-def getBv_current(type,CURR,DIM,POSm,POSo,ANG=[],AX=[],ANCH=[]):
-    """
-    Calculate the field of currents using vectorized performance code.
-
-    Parameters
-    ----------
-
-    type : string
-        source type either 'circular' or 'line'
-
-    MAG : Nx3 numpy array float [mT]
-        vector of N magnetizations.
-
-    DIM : NxY numpy array float [mm]
-        vector of N dimensions for each evaluation. The form of this vector depends
-        on the source type. Y=1/3x3 for circular/line.
-
-    POSo : Nx3 numpy array float [mm]
-        vector of N positions of the observer.
-    
-    POSm : Nx3 numpy array float [mm]
-        vector of N initial source positions. These positions will be adjusted by
-        the given rotation parameters.
-
-    ANG=[] : length M list of size N numpy arrays float [deg]
-       Angles of M subsequent rotation operations applied to the N-sized POSm and
-       the implicit source orientation.
-    
-    AX=[] : length M list of Nx3 numpy arrays float []
-        Axis vectors of M subsequent rotation operations applied to the N-sized
-        POSm and the implicit source orientation.
-    
-    ANCH=[] : length M list of Nx3 numpy arrays float [mm]
-        Anchor positions of M subsequent rotations applied ot the N-sized POSm and
-        the implicit source orientation.
-    """
-    N = len(POSo)
-
-    Q = np.zeros([N,4])
-    Q[:,0] = 1              # init orientation
-    
-    Pm = POSm               #initial position
-
-    #apply rotation operations
-    for ANGLE,AXIS,ANCHOR in zip(ANG,AX,ANCH):
-        Q = QmultV(getRotQuatV(ANGLE,AXIS),Q)
-        Pm = angleAxisRotationV_priv(ANGLE,AXIS,Pm-ANCHOR)+ANCHOR
-
-    # transform into CS of source
-    POSrel = POSo-Pm        #relative position
-    Qc = QconjV(Q)          #orientierung
-    POSrot = QrotationV(Qc,POSrel)  #rotation der pos in das CS der Quelle
-
-    # calculate field
-    if type == 'circular':
-        Brot = Bfield_CircularCurrentLoopV(CURR, DIM, POSrot)
-    elif type == 'line':
-        Brot = Bfield_LineSegmentV(POSrot,DIM[:,0],DIM[:,1],CURR)
-    else:
-        print('Bad type')
-        return 0
-    
-    # transform back
-    B = QrotationV(Q,Brot)  #rückrotation des feldes
-    
-    return B
-
-# -------------------------------------------------------------------------------
-# -------------------------------------------------------------------------------
-
-def getBv_moment(type,MOM,POSm,POSo,ANG=[],AX=[],ANCH=[]):
-    """
-    Calculate the field of magnetic moments using vectorized performance code.
-
-    Parameters
-    ----------
-
-    type : string
-        source type: 'dipole'
-
-    MOM : Nx3 numpy array float [mT]
-        vector of N dipole moments.
-
-    POSo : Nx3 numpy array float [mm]
-        vector of N positions of the observer.
-    
-    POSm : Nx3 numpy array float [mm]
-        vector of N initial source positions. These positions will be adjusted by
-        the given rotation parameters.
-
-    ANG=[] : length M list of size N numpy arrays float [deg]
-       Angles of M subsequent rotation operations applied to the N-sized POSm and
-       the implicit source orientation.
-    
-    AX=[] : length M list of Nx3 numpy arrays float []
-        Axis vectors of M subsequent rotation operations applied to the N-sized
-        POSm and the implicit source orientation.
-    
-    ANCH=[] : length M list of Nx3 numpy arrays float [mm]
-        Anchor positions of M subsequent rotations applied ot the N-sized POSm and
-        the implicit source orientation.
-    """
-
-    N = len(POSo)
-
-    Q = np.zeros([N,4])
-    Q[:,0] = 1              # init orientation
-    
-    Pm = POSm               #initial position
-
-    #apply rotation operations
-    for ANGLE,AXIS,ANCHOR in zip(ANG,AX,ANCH):
-        Q = QmultV(getRotQuatV(ANGLE,AXIS),Q)
-        Pm = angleAxisRotationV_priv(ANGLE,AXIS,Pm-ANCHOR)+ANCHOR
-
-    # transform into CS of source
-    POSrel = POSo-Pm        #relative position
-    Qc = QconjV(Q)          #orientierung
-    POSrot = QrotationV(Qc,POSrel)  #rotation der pos in das CS der Quelle
-
-    # calculate field
-    if type == 'dipole':
-        Brot = Bfield_DipoleV(MOM, POSrot)
-    else:
-        print('Bad type')
-        return 0
-    
-    # transform back
-    B = QrotationV(Q,Brot)  #rückrotation des feldes
-    
-    return B
diff --git a/magpylib/_lib/mathLib.py b/magpylib/_lib/mathLib.py
deleted file mode 100644
index 33029a9a6..000000000
--- a/magpylib/_lib/mathLib.py
+++ /dev/null
@@ -1,365 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-import numpy
-from numpy import arctan, pi, array, sqrt, NaN, cos, sin, arccos, float64, sqrt
-
-# SMOOTH VERSION OF ARCTAN ------------------------------------------------------
-# get a smooth version of the cylindrical coordinates
-# similar to atan2 from numpy - replace at some point
-
-def getPhi(x, y):
-    if x > 0:
-        return arctan(y/x)
-    elif x < 0:
-        if y >= 0:
-            return arctan(y/x)+pi
-        else:
-            return arctan(y/x)-pi
-    else:
-        if y > 0:
-            return pi/2
-        elif y<0:
-            return -pi/2
-        else:
-            return 0
-
-
-# NUMERICALY STABLE VERSION OF ARCCOS -------------------------------------------
-# avoid numerical problem to evaluate at 1.000000000001
-def arccosSTABLE(x):
-    if 1 > x > -1:
-        return arccos(x)
-    elif x >= 1:
-        return 0
-    elif x <= -1:
-        return pi
-
-
-# FAST VERSIONS OF 3D VECTOR ALGEBRA --------------------------------------------
-
-# more than 10-times faster than native np.cross (which is for arbitrary dimensions)
-def fastCross3D(u, v):
-    return array([u[1]*v[2]-u[2]*v[1], -u[0]*v[2]+u[2]*v[0], u[0]*v[1]-u[1]*v[0]])
-
-# much faster than sum()
-
-
-def fastSum3D(u):
-    return u[0]+u[1]+u[2]
-
-# much faster than np.la.norm (which is for arbitrary dimensions)
-
-
-def fastNorm3D(u):
-    return sqrt(u[0]**2+u[1]**2+u[2]**2)
-
-
-# QUATERNIONS for ANGLE-AXIS ROTATION --------------------------------------------
-# Quaterntions are defined as 4D Lists
-
-# Quaternion multiplication
-def Qmult(Q, P):
-    r0 = Q[0]*P[0] - Q[1]*P[1] - Q[2]*P[2] - Q[3]*P[3]
-    r1 = Q[0]*P[1] + Q[1]*P[0] + Q[2]*P[3] - Q[3]*P[2]
-    r2 = Q[0]*P[2] - Q[1]*P[3] + Q[2]*P[0] + Q[3]*P[1]
-    r3 = Q[0]*P[3] + Q[1]*P[2] - Q[2]*P[1] + Q[3]*P[0]
-    return [r0, r1, r2, r3]
-
-# Quaternion Norm**2
-
-
-def Qnorm2(Q):
-    return Q[0]**2 + Q[1]**2 + Q[2]**2 + Q[3]**2
-
-# Unit Quaternion
-
-
-def Qunit(Q):
-    qnorm = sqrt(Qnorm2(Q))
-    return [q/qnorm for q in Q]
-
-# Conjugate Quaternion
-
-
-def Qconj(Q):
-    return array([Q[0], -Q[1], -Q[2], -Q[3]])
-
-# getRotationQuaternion from axis angle (see Kuipers p.131)
-
-
-def getRotQuat(angle, axis):
-    Lax = fastNorm3D(axis)
-    Uax = axis/Lax
-
-    Phi = angle/180*pi/2
-    cosPhi = cos(Phi)
-    sinPhi = sin(Phi)
-
-    Q = [cosPhi] + [a*sinPhi for a in Uax]
-
-    return Q
-
-# Angle-Axis Rotation of Vector
-
-
-def angleAxisRotation_priv(angle, axis, v):
-    P = getRotQuat(angle, axis)
-
-    Qv = [0, v[0], v[1], v[2]]
-    Qv_new = Qmult(P, Qmult(Qv, Qconj(P)))
-
-    return array(Qv_new[1:])
-
-
-# %% ELLIPTICAL INTEGRALS
-
-#from scipy.integrate import quad
-# Algorithm to determine a special elliptic integral
-# Algorithm proposed in Derby, Olbert 'Cylindrical Magnets and Ideal Solenoids'
-# arXiev:00909.3880v1
-
-def elliptic(kc, p, c, s):
-    if kc == 0:
-        return NaN
-    errtol = .000001
-    k = abs(kc)
-    pp = p
-    cc = c
-    ss = s
-    em = 1.
-    if p > 0:
-        pp = sqrt(p)
-        ss = s/pp
-    else:
-        f = kc*kc
-        q = 1.-f
-        g = 1. - pp
-        f = f - pp
-        q = q*(ss - c*pp)
-        pp = sqrt(f/g)
-        cc = (c-ss)/g
-        ss = -q/(g*g*pp) + cc*pp
-    f = cc
-    cc = cc + ss/pp
-    g = k/pp
-    ss = 2*(ss + f*g)
-    pp = g + pp
-    g = em
-    em = k + em
-    kk = k
-    while abs(g-k) > g*errtol:
-        k = 2*sqrt(kk)
-        kk = k*em
-        f = cc
-        cc = cc + ss/pp
-        g = kk/pp
-        ss = 2*(ss + f*g)
-        pp = g + pp
-        g = em
-        em = k+em
-    return(pi/2)*(ss+cc*em)/(em*(em+pp))
-
-# complete elliptic integral of the first kind: ellipticK
-# E(x) = \int_0^pi/2 (1-x sin(phi)^2)^(-1/2) dphi
-# Achtung: fur x>1 wird der output imaginaer und die derzeitigen algorithmen brechen zusammen
-
-
-def ellipticK(x):
-    return elliptic((1-x)**(1/2.), 1, 1, 1)
-
-# complete elliptic integral of the second kind: ellipticE
-# E(x) = \int_0^pi/2 (1-x sin(phi)^2)^(1/2) dphi
-# Achtung: fur x>1 wird der output imaginaer und die derzeitigen algorithmen brechen zusammen
-
-
-def ellipticE(x):
-    return elliptic((1-x)**(1/2.), 1, 1, 1-x)
-
-# complete elliptic integral of the third kind: ellipticPi
-
-
-def ellipticPi(x, y):
-    return elliptic((1-y)**(1/2.), 1-x, 1, 1)
-
-# TESTING ALGORITHM ---------------------------------------------------------
-# def integrand(phi,kc,p,c,s):
-#    return (c*cos(phi)**2+s*sin(phi)**2)/(cos(phi)**2+p*sin(phi)**2)/sqrt(cos(phi)**2+kc**2*sin(phi)**2)
-
-# def nelliptic(kc,p,c,s):
-#    I = quad(integrand,0,pi/2,args=(kc,p,c,s))
-#    return I
-
-#from scipy.integrate import quad
-# def integrand(phi,x):
-#    return (1-x*sin(phi)**2)**(-1/2.)
-# def nelliptic(x):
-#    I = quad(integrand,0,pi/2,args=x)
-#    return I
-# print(nelliptic(-.51))
-
-
-
-
-def randomAxis():
-    """
-    This function generates a random `axis` (3-vector of length 1) from an equal
-    angular distribution using a MonteCarlo scheme.
-
-    Returns
-    -------
-    axis : arr3
-        A random axis from an equal angular distribution of length 1
-
-    Example
-    -------
-    >>> magpylib as magPy
-    >>> ax = magPy.math.randomAxis()
-    >>> print(ax)
-      [-0.24834468  0.96858637  0.01285925]
-
-    """
-    while True:
-        r = numpy.random.rand(3)*2-1  # create random axis
-        Lr2 = sum(r**2)  # get length
-        if Lr2 <= 1:  # is axis within sphere?
-            Lr = sqrt(Lr2)  # normalize
-            return r/Lr
-
-
-def axisFromAngles(angles):
-    """
-    This function generates an `axis` (3-vector of length 1) from two `angles` = [phi,th]
-    that are defined as in spherical coordinates. phi = azimuth angle, th = polar angle.
-    Vector input format can be either list, tuple or array of any data type (float, int).
-
-    Parameters
-    ----------
-    angles : vec2 [deg]
-        The two angels [phi,th], azimuth and polar, in units of deg.
-
-    Returns    
-    -------
-    axis : arr3
-        An axis of length that is oriented as given by the input angles.
-
-    Example
-    -------
-    >>> magpylib as magPy
-    >>> angles = [90,90]
-    >>> ax = magPy.math.axisFromAngles(angles)
-    >>> print(ax)
-      [0.0  1.0  0.0]
-    """
-    phi, th = angles  # phi in [0,2pi], th in [0,pi]
-    phi = phi/180*pi
-    th = th/180*pi
-    return array([cos(phi)*sin(th), sin(phi)*sin(th), cos(th)])
-
-
-def anglesFromAxis(axis):
-    """
-    This function takes an arbitrary `axis` (3-vector) and returns the orientation
-    given by the `angles` = [phi,th] that are defined as in spherical coordinates. 
-    phi = azimuth angle, th = polar angle. Vector input format can be either 
-    list, tuple or array of any data type (float, int).
-
-    Parameters
-    ----------
-    axis : vec3
-        Arbitrary input axis that defines an orientation.
-
-    Returns
-    -------
-    angles : arr2 [deg]
-        The angles [phi,th], azimuth and polar, that anchorrespond to the orientation 
-        given by the input axis.
-
-    Example
-    -------
-    >>> magpylib as magPy
-    >>> axis = [1,1,0]
-    >>> angles = magPy.math.anglesFromAxis(axis)
-    >>> print(angles)
-      [45. 90.]
-    """
-    ax = array(axis, dtype=float64, copy=False)
-
-    Lax = fastNorm3D(ax)
-    Uax = ax/Lax
-
-    TH = arccos(Uax[2])/pi*180
-    PHI = getPhi(Uax[0], Uax[1])/pi*180
-    return array([PHI, TH])
-
-
-def angleAxisRotation(position, angle, axis, anchor=[0, 0, 0]):
-    """
-    This function uses angle-axis rotation to rotate the `position` vector by
-    the `angle` argument about an axis defined by the `axis` vector which passes
-    through the center of rotation `anchor` vector. Scalar input is either integer
-    or float.Vector input format can be either list, tuple or array of any data
-    type (float, int).
-
-    Parameters
-    ----------
-    position : vec3
-        Input position to be rotated.
-
-    angle : scalar [deg]
-        Angle of rotation in untis of [deg]
-
-    axis : vec3
-        Axis of rotation
-
-    anchor : vec3
-        The Center of rotation which defines the position of the axis of rotation
-
-    Returns    
-    -------
-    newPosition : arr3
-        Rotated position
-
-    Example
-    -------
-    >>> magpylib as magPy
-    >>> from numpy import pi
-    >>> position0 = [1,1,0]
-    >>> angle = -90
-    >>> axis = [0,0,1]
-    >>> centerOfRotation = [1,0,0]
-    >>> positionNew = magPy.math.angleAxisRotation(position0,angle,axis,anchor=centerOfRotation)
-    >>> print(positionNew)
-      [2. 0. 0.]
-    """
-
-    pos = array(position, dtype=float64, copy=False)
-    ang = float(angle)
-    ax = array(axis, dtype=float64, copy=False)
-    anchor = array(anchor, dtype=float64, copy=False)
-
-    pos12 = pos-anchor
-    pos12Rot = angleAxisRotation_priv(ang, ax, pos12)
-    posRot = pos12Rot+anchor
-
-    return posRot
diff --git a/magpylib/_lib/mathLib_vector.py b/magpylib/_lib/mathLib_vector.py
deleted file mode 100644
index 54a9af6de..000000000
--- a/magpylib/_lib/mathLib_vector.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-import numpy as np
-
-def QmultV(Q, P):
-    """
-    Implementation of the quaternion multiplication
-    """
-    Sig = np.array([[1,-1,-1,-1],[1,1,1,-1],[1,-1,1,1],[1,1,-1,1]])
-    M = Q*np.array([P,np.roll(P[:,::-1],2,axis=1),np.roll(P,2,axis=1),P[:,::-1]])
-    M = np.swapaxes(M,0,1)
-    return np.sum(M*Sig,axis=2)
-
-
-def QconjV(Q):
-    """
-    Implementation of the conjugation of a quaternion
-    """
-    Sig = np.array([1,-1,-1,-1])
-    return Q*Sig
-
-
-def getRotQuatV(ANGLE, AXIS):
-    """
-    ANGLE in [deg], AXIS dimensionless
-    vectorized version of getRotQuat, returns the rotation quaternion which 
-    describes the rotation given by angle and axis (see paper)
-    NOTE: axis cannot be [0,0,0] !!! this would not describe a rotation. however
-        sinPhi = 0 returns a 0-axis (but just in the Q this must still be 
-        interpreted correctly as an axis)
-    """
-    Lax = np.linalg.norm(AXIS,axis=1)
-    Uax = AXIS/Lax[:,None]   # normalize
-
-    Phi = ANGLE/180*np.pi/2
-    cosPhi = np.cos(Phi)
-    sinPhi = np.sin(Phi)
-    
-    Q = np.array([cosPhi] + [Uax[:,i]*sinPhi for i in range(3)])
-
-    return np.swapaxes(Q,0,1)
-
-def QrotationV(Q,v):
-    """
-    replaces angle axis rotation by direct Q-rotation to skip this step speed
-    when multiple subsequent rotations are given
-    """
-    Qv = np.pad(v,((0,0),(1,0)), mode='constant') 
-    Qv_new = QmultV(Q, QmultV(Qv, QconjV(Q)))
-    return Qv_new[:,1:]
-
-
-def getAngAxV(Q):
-    # UNUSED - KEEP FOR UNDERSTANDING AND TESTING
-    # returns angle and axis for a quaternion orientation input
-    angle = np.arccos(Q[:,0])*180/np.pi*2
-    axis = Q[:,1:]
-    
-    # a quaternion with a 0-axis describes a unit rotation (0-angle).
-    # there should still be a proper axis output but it is eliminated
-    # by the term [Uax[:,i]*sinPhi for i in range(3)]) with sinPhi=0.
-    # since for 0-angle the axis doesnt matter we can set it to [0,0,1] 
-    # which is our defined initial orientation
-    
-    Lax = np.linalg.norm(axis,axis=1)
-    mask = Lax!=0
-    Uax = np.array([[0,0,1.]]*len(axis))     # set all to [0,0,1]
-    Uax[mask] = axis[mask]/Lax[mask,None]   # use mask to normalize non-zeros
-    return angle,Uax
-
-
-def angleAxisRotationV_priv(ANGLE, AXIS, V):
-    # vectorized version of angleAxisRotation_priv
-    P = getRotQuatV(ANGLE, AXIS)
-    Qv = np.pad(V,((0,0),(1,0)), mode='constant') 
-    Qv_new = QmultV(P, QmultV(Qv, QconjV(P)))
-    return Qv_new[:,1:]
-
-
-def randomAxisV(N):
-    """
-    This is the vectorized version of randomAxis(). It generates an 
-    N-sized vector of random `axes` (3-vector of length 1) from equal 
-    angular distributions using a MonteCarlo scheme.
-
-    Parameters
-    -------
-    N : int
-        Size of random axis vector.
-
-    Returns
-    -------
-    axes : Nx3 arr
-        A  vector of random axes from an equal angular distribution of length 1.
-
-    Example
-    -------
-    >>> import magpylib as magpy
-    >>> import magpylib as magpy
-    >>> AXS = magpy.math.randomAxisV(3)
-    >>> print(AXS)
-    >>> # Output: [[ 0.39480364 -0.53600779 -0.74620757]
-    ... [ 0.02974442  0.10916333  0.9935787 ]
-    ... [-0.54639126  0.76659756 -0.33731997]]
-    """
-
-    R = np.random.rand(N,3)*2-1
-        
-    while True:
-        lenR = np.linalg.norm(R,axis=1)
-        mask = lenR > 1  #bad = True
-        Nbad = np.sum(mask)
-        if Nbad==0:
-            return R/lenR[:,np.newaxis]
-        else:
-            R[mask] = np.random.rand(Nbad,3)*2-1
-
-
-
-def axisFromAnglesV(ANG):
-    """
-    This is the vectorized version of axisFromAngles(). It generates an Nx3 
-    array of axis vectors from the Nx2 array of input angle pairs angles. 
-    Each angle pair is (phi,theta) which are azimuth and polar angle of a 
-    spherical coordinate system respectively.
-
-    Parameters
-    ----------
-    ANG : arr Nx2 [deg]
-        An N-sized array of angle pairs [phi th], azimuth and polar, in 
-        units of deg.
-
-    Returns    
-    -------
-    AXIS : arr Nx3
-        An N-sized array of unit axis vectors oriented as given by the input ANG.
-    
-    Example
-    -------
-    >>> import magpylib as magpy
-    >>> import numpy as np
-    >>> ANGS = np.array([[0,90],[90,180],[90,0]])
-    >>> AX = magpy.math.axisFromAnglesV(ANGS)
-    >>> print(np.around(AX,4))
-    >>> # Output: [[1.  0. 0.]  [0. 0. -1.]  [0. 0. 1.]]
-    """
-    PHI = ANG[:,0]/180*np.pi
-    TH = ANG[:,1]/180*np.pi
-
-    return np.array([np.cos(PHI)*np.sin(TH), np.sin(PHI)*np.sin(TH), np.cos(TH)]).transpose()
-
-
-
-def anglesFromAxisV(AXIS):
-    """
-    This is the vectorized version of anglesFromAxis(). It takes an Nx3 array 
-    of axis-vectors and returns an Nx2 array of angle pairs. Each angle pair is 
-    (phi,theta) which are azimuth and polar angle in a spherical coordinate 
-    system respectively.
-
-    Parameters
-    ----------
-    AXIS : arr Nx3
-        N-sized array of axis-vectors (do not have to be not be normalized).
-
-    Returns
-    -------
-    ANGLES : arr Nx2 [deg]
-        N-sized array of angle pairs [phi,th], azimuth and polar, that 
-        chorrespond to the orientations given by the input axis vectors 
-        in a spherical coordinate system.
-     
-    Example
-    -------
-    >>> import numpy as np
-    >>> import magpylib as magpy
-    >>> AX = np.array([[0,0,1],[0,0,1],[1,0,0]])
-    >>> ANGS = magpy.math.anglesFromAxisV(AX)
-    >>> print(ANGS)
-    >>> # Output: [[0. 0.]  [90. 90.]  [0. 90.]])
-    """
-
-    Lax = np.linalg.norm(AXIS,axis=1)
-    Uax = AXIS/Lax[:,np.newaxis]
-
-    TH = np.arccos(Uax[:,2])/np.pi*180
-    PHI = np.arctan2(Uax[:,1], Uax[:,0])/np.pi*180
-    return np.array([PHI, TH]).transpose()
-
-
-
-def angleAxisRotationV(POS,ANG,AXIS,ANCHOR):
-    """
-    This is the vectorized version of angleAxisRotation(). Each entry 
-    of POS (arrNx3) is rotated according to the angles ANG (arrN), 
-    about the axis vectors AXS (arrNx3) which pass throught the anchors 
-    ANCH (arrNx3) where N refers to the length of the input vectors.
-
-    Parameters
-    ----------
-    POS : arrNx3
-        The input vectors to be rotated.
-
-    ANG : arrN [deg]
-        Rotation angles in units of [deg].
-
-    AXIS : arrNx3
-        Vector of rotation axes.
-
-    anchor : arrNx3
-        Vector of rotation anchors.
-
-    Returns    
-    -------
-    newPOS : arrNx3
-        Vector of rotated positions.
-
-    >>> import magpylib as magpy
-    >>> import numpy as np
-    >>> POS = np.array([[1,0,0]]*5) # avoid this slow Python loop
-    >>> ANG = np.linspace(0,180,5)
-    >>> AXS = np.array([[0,0,1]]*5) # avoid this slow Python loop
-    >>> ANCH = np.zeros((5,3))
-    >>> POSnew = magpy.math.angleAxisRotationV(POS,ANG,AXS,ANCH)
-    >>> print(np.around(POSnew,4))
-    >>> # Output: [[ 1.      0.      0.    ]
-    ...            [ 0.7071  0.7071  0.    ]
-    ...            [ 0.      1.      0.    ]
-    ...            [-0.7071  0.7071  0.    ]
-    ...            [-1.      0.      0.    ]]
-    """
-
-    POS12 = POS-ANCHOR
-    POS12rot = angleAxisRotationV_priv(ANG,AXIS,POS12)
-    POSnew = POS12rot+ANCHOR
-
-    return POSnew
-
-
-
-def ellipticV(kc,p,c,s):
-    '''
-    vectorized version of the elliptic integral
-    original implementation from paper Kirby
-    '''
-
-    #if kc == 0:
-    #    return NaN
-    errtol = .000001
-    N = len(kc)
-    
-    k = np.abs(kc)
-    em = np.ones(N,dtype=float)
-
-    cc = c.copy()
-    pp = p.copy()
-    ss = s.copy()
-    
-    # apply a mask for evaluation of respective cases
-    mask = p>0
-    maskInv = np.invert(mask)
-
-    #if p>0:
-    pp[mask] = np.sqrt(p[mask])
-    ss[mask] = s[mask]/pp[mask]
-
-    #else:
-    f = kc[maskInv]*kc[maskInv]
-    q = 1.-f
-    g = 1. - pp[maskInv]
-    f = f - pp[maskInv]
-    q = q*(ss[maskInv] - c[maskInv]*pp[maskInv])
-    pp[maskInv] = np.sqrt(f/g)
-    cc[maskInv] = (c[maskInv]-ss[maskInv])/g
-    ss[maskInv] = -q/(g*g*pp[maskInv]) + cc[maskInv]*pp[maskInv]
-
-    f = cc.copy()
-    cc = cc + ss/pp
-    g = k/pp
-    ss = 2*(ss + f*g)
-    pp = g + pp
-    g = em.copy()
-    em = k + em
-    kk = k.copy()
-
-    #define a mask that adjusts with every evauation
-    #   step so that only non-converged entries are
-    #   further iterated.   
-    mask = np.ones(N,dtype=bool)
-    while np.any(mask):
-        k[mask] = 2*np.sqrt(kk[mask])
-        kk[mask] = np.copy(k[mask]*em[mask])
-        f[mask] = cc[mask]
-        cc[mask] = cc[mask] + ss[mask]/pp[mask]
-        g[mask] = kk[mask]/pp[mask]
-        ss[mask] = 2*(ss[mask] + f[mask]*g[mask])
-        pp[mask] = g[mask] + pp[mask]
-        g[mask] = em[mask]
-        em[mask] = k[mask]+em[mask]
-
-        #redefine mask so only non-convergent 
-        #   entries are reiterated
-        mask = (np.abs(g-k) > g*errtol)
-
-    return(np.pi/2)*(ss+cc*em)/(em*(em+pp))
-
-
-
-def ellipticKV(x):
-    '''
-    special case complete elliptic integral of first kind ellipticK
-    0 <= x <1
-    '''
-    N = len(x)
-    onez = np.ones([N])
-    return ellipticV((1-x)**(1/2.), onez, onez, onez)
-
-
-
-def ellipticEV(x):
-    '''
-    special case complete elliptic integral of second kind ellipticE
-    E(x) = int_0^pi/2 (1-x sin(phi)^2)^(1/2) dphi
-    requires x < 1 ! 
-    '''
-    N = len(x)
-    onez = np.ones([N])
-    return ellipticV((1-x)**(1/2.), onez, onez, 1-x)
-
-
-
-def ellipticPiV(x, y):
-    '''
-    special case complete elliptic integral of third kind ellipticPi
-    E(x) = int_0^pi/2 (1-x sin(phi)^2)^(1/2) dphi
-    requires x < 1 ! 
-    '''
-    N = len(x)
-    onez = np.ones([N])
-    return ellipticV((1-y)**(1/2.), 1-x, onez, onez)
\ No newline at end of file
diff --git a/magpylib/_lib/utility.py b/magpylib/_lib/utility.py
deleted file mode 100644
index d598f29fc..000000000
--- a/magpylib/_lib/utility.py
+++ /dev/null
@@ -1,322 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-from typing import Tuple
-from numpy import float64, isnan, array
-
-
-# Helper function for validating input dimensions
-def checkDimensions(expectedD: int, dim: Tuple[float, float, float], exitMsg: str = "Bad dim input") -> array:
-    if type(dim) == int or type(dim) == float:
-        dim = [dim]
-    assert all(coord == 0 for coord in dim) is False, exitMsg + \
-        ", all values are zero"
-    dimension = array(dim, dtype=float64, copy=False)
-    assert (not any(isnan(dimension)) and len(dimension) == expectedD), exitMsg
-    return dimension
-
-
-# Collection Helpers
-def addListToCollection(sourceList, inputList, dupWarning):
-    assert all(isSource(a)
-               for a in inputList), "Non-source object in Collection initialization"
-    if dupWarning is True:  # Skip iterating both lists if warnings are off
-        for source in inputList:
-            # Checks if source is in list, throw warning
-            addUniqueSource(source, sourceList)
-    else:
-        sourceList.extend(inputList)
-
-
-def isSource(theObject: any) -> bool:
-    """
-    Check is an object is a magnetic source.
-
-    Parameter
-    ---------
-        theObject: any
-            Object to be evaluated if it is a source. Update list when new sources are up
-    Returns
-    -------
-        bool
-    """
-    from magpylib import source
-    sourcesList = (
-        source.magnet.Box,
-        source.magnet.Sphere,
-        source.magnet.Cylinder,
-        source.current.Line,
-        source.current.Circular,
-        source.moment.Dipole)
-    return any(isinstance(theObject, src) for src in sourcesList)
-
-
-def isSensor(theObject: any) -> bool:
-    from magpylib._lib.classes.sensor import Sensor 
-    return isinstance(theObject,Sensor)
-
-
-def addUniqueSource(source, sourceList):
-    import warnings
-    if source not in sourceList:
-        sourceList += [source]
-    else:
-        warnings.warn("Source " + str(source) +
-                      " already in Collection list; Ignoring", Warning)
-####
-
-
-def drawMagnetizationVector(position, magnetization, angle, axis, color, SYSSIZE, ax):
-    """Draw the magnetization vector of a magnet.
-
-    Parameters
-    ----------
-    position : vec3
-        position of the magnet
-    magnetization : vec3
-        magnetization vector
-    angle : float
-        angle of rotation
-    axis : vec3
-        Axis of rotation
-    color : matplotlib color
-        Color of the axis. No default value specified
-    SYSSIZE : float
-        Size of the display syste
-    ax : [pyploy]
-        pyplot canvas to draw on
-
-    """
-    from magpylib._lib.mathLib import angleAxisRotation
-    M = angleAxisRotation(magnetization, angle, axis)
-    P = position
-    # Get a lil different but unique tone
-    c = [color[0]/2, color[1]/2, color[2]/2, color[3]]
-    ax.quiver(P[0], P[1], P[2],  # X,Y,Z position
-                  M[0], M[1], M[2],  # Components of the Vector
-                  normalize=True,
-                  length=SYSSIZE,
-                  color=c)
-
-def drawSensor(sensor, SYSSIZE, ax):
-    """Draw the sensor coordinates
-
-    Parameters
-    ----------
-    sensor: Sensor
-        Sensor to draw
-    SYSSIZE : float
-        Size of the display system
-    ax : [pyplot]
-        pyplot canvas to draw on
-
-    """
-    from magpylib._lib.mathLib import angleAxisRotation
-    M = angleAxisRotation([1,0,0],sensor.angle,sensor.axis)
-    P = sensor.position
-    ax.quiver(P[0], P[1], P[2],  # X position
-              M[0], M[1], M[2],  # Components of the Vector
-                  normalize=True,
-                  length=SYSSIZE/4,
-                  color='r')
-    ax.text(M[0]+P[0], M[1]+P[1], M[2]+P[2], "x", None)
-    
-    M = angleAxisRotation([0,1,0],sensor.angle,sensor.axis)
-    ax.quiver(P[0], P[1], P[2],  # Y position
-              M[0], M[1], M[2],  # Components of the Vector
-                  normalize=True,
-                  length=SYSSIZE/4,
-                  color='g')
-    ax.text(M[0]+P[0], M[1]+P[1], M[2]+P[2], "y", None)
-    
-    M = angleAxisRotation([0,0,1],sensor.angle,sensor.axis)
-    ax.quiver(P[0], P[1], P[2],  # Z position
-              M[0], M[1], M[2],  # Components of the Vector
-                  normalize=True,
-                  length=SYSSIZE/4,
-                  color='b')
-    ax.text(M[0]+P[0], M[1]+P[1], M[2]+P[2], "z", None)
-
-def drawMagAxis(magnetList, SYSSIZE, ax):
-    """
-    Draws the magnetization vectors of magnet objects in a list.
-
-    Parameters
-    ----------
-    magnetList: [list]
-        list of magnet objects with a "color" attribute.
-        Do source.color = 'k' in the meantime if there isnt any
-        before appending it to the list.
-
-    SYSSIZE : [float]
-        [Size of the display system]
-    pyplot : [pyplot]
-        [Pyplot canvas]
-
-    """
-
-    for s in magnetList:
-        drawMagnetizationVector(s.position, s.magnetization,
-                                s.angle, s.axis, s.color,
-                                SYSSIZE, ax)
-
-####
-
-
-def drawLineArrows(vertices, current, SYSSIZE, ax):
-    """
-    Helper function for Collection.displaySystem()
-    Draw Arrows inside the line to show current orientation
-
-    Parameters
-    ----------
-    vertices : [list]
-            A list of position lists of each vertix.
-    current : [float]
-            The current. Polarity Inverts the orientation.
-    SYSSIZE : [type]
-            Size of the System for controlling arrow size.
-    pyplot : [pyplot]
-            The pyplot instance
-
-    """
-
-    lenli = len(vertices)
-    for v in range(0, len(vertices)-1):
-        # Get last position if current is position
-        x = vertices[(-(v+1), v)[current <= 0]]
-        y = vertices[(-((v+2) % lenli), (v+1) % lenli)
-                     [current <= 0]]  # Get second to last
-        ax.quiver((x[0]+y[0])/2, (x[1]+y[1])/2, (x[2]+y[2])/2,  # Mid point in line
-                      # Components of the Vector
-                      x[0]-y[0], x[1]-y[1], x[2]-y[2],
-                      normalize=True,
-                      length=SYSSIZE/12,
-                      color='k')
-
-        ax.quiver(y[0], y[1], y[2],  # Arrow at start
-                      # Components of the Vector
-                      x[0]-y[0], x[1]-y[1], x[2]-y[2],
-                      normalize=True,
-                      length=SYSSIZE/12,
-                      color='k')
-
-
-def drawCurrentArrows(currentList, SYSSIZE, ax):
-    for s in currentList:
-        drawLineArrows(s.vertices, s.current, SYSSIZE, ax)
-
-###
-
-
-def drawDipole(position, moment, angle, axis, SYSSIZE, ax):
-    """
-    Draw a dipole moment arrow.
-
-    Parameters
-    ----------
-    position : vec3
-        position of the dipole
-    moment : vec3
-        orientation vector of the dipole
-    SYSSIZE : float
-        size of the display
-    pyplot : pyplot
-        canvas to draw on
-
-    """
-    from magpylib._lib.mathLib import angleAxisRotation
-    P = angleAxisRotation(position, angle, axis)
-    M = angleAxisRotation(moment, angle, axis)
-    
-    ax.quiver(P[0], P[1], P[2],  # X,Y,Z position
-                  M[0], M[1], M[2],  # Components of the Vector
-                  normalize=True,
-                  length=SYSSIZE/12,
-                  color='k')
-
-
-# Source package helpers
-
-
-def recoordinateAndGetB(source_ref, args):
-    ## Used in base.RCS.getBDisplacement(),
-    # Take an object, a sample position to place the object in
-    # and magnet orientation arguments.
-    # Apply the new position, orient it, and return the B field value from position Bpos.
-    Bpos = args[0]
-    Mpos = args[1]
-    MOrient = args[2]
-    angle = MOrient[0]
-    axis = MOrient[1]
-
-    assert isPosVector(Mpos)
-    assert isPosVector(Bpos)
-    assert isPosVector(axis)
-    assert isinstance(angle, float) or isinstance(angle, int)
-
-    source_ref.setPosition(Mpos)
-    # if len(MOrient)==3:
-    #     anchor = MOrient[3]
-    #     assert isPosVector(anchor)
-    #     source_ref.rotate(  angle,
-    #                         axis,
-    #                         anchor)
-    # else:
-    source_ref.setOrientation(angle,
-                              axis)
-
-    return source_ref.getB(Bpos)
-
-
-def isPosVector(object_ref):
-    # Return true if the object reference is that of
-    # a position array.
-    from numpy import array, ndarray
-    try:
-        if (isinstance(object_ref, list) or isinstance(object_ref, tuple) or isinstance(object_ref, ndarray) or isinstance(object_ref, array)):
-            if len(object_ref) == 3:
-                return all(isinstance(int(coordinate), int) for coordinate in object_ref)
-    except Exception:
-        return False
-
-
-def initializeMulticorePool(processes):
-    # Helper for setting up Multicore pools.
-    from multiprocessing import Pool, cpu_count
-    if processes == 0:
-        # Identify how many workers the host machine can take.
-        processes = cpu_count() - 1
-        # Using all cores is USUALLY a bad idea.
-    assert processes > 0, "Could not identify multiple cores for getB. This machine may not support multiprocessing."
-    return Pool(processes=processes)
-
-
-def isDisplayMarker(object_ref):
-    m = object_ref
-    if len(m) == 3:  # Check if it's [numeric,numeric,numeric]
-        return all(isinstance(p, int) or isinstance(p, float) for p in m)
-    if len(m) == 4:  # Check if it's [numeric,numeric,numeric,"label"]
-        return all(isinstance(p, int) or isinstance(p, float) for p in m[:2]) and isinstance(m[3], str)
-
-
diff --git a/magpylib/math/__init__.py b/magpylib/math/__init__.py
deleted file mode 100644
index 4db5d46ae..000000000
--- a/magpylib/math/__init__.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-This module includes several practical functions for working with axis-angle
-relations and genmeralized rotations.
-"""
-
-__all__ = ["randomAxis", "randomAxisV", "axisFromAngles", "axisFromAnglesV",
-           "anglesFromAxis", "anglesFromAxisV",
-           "angleAxisRotation", "angleAxisRotationV"]  # This is for Sphinx
-
-from magpylib._lib.mathLib import randomAxis
-from magpylib._lib.mathLib import axisFromAngles
-from magpylib._lib.mathLib import anglesFromAxis
-from magpylib._lib.mathLib import angleAxisRotation
-
-from magpylib._lib.mathLib_vector import randomAxisV
-from magpylib._lib.mathLib_vector import axisFromAnglesV
-from magpylib._lib.mathLib_vector import anglesFromAxisV
-from magpylib._lib.mathLib_vector import angleAxisRotationV
diff --git a/magpylib/source/__init__.py b/magpylib/source/__init__.py
deleted file mode 100644
index 5f640dee2..000000000
--- a/magpylib/source/__init__.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-
-"""
-All available sources are collected here, accessible through the following
-subpackages: `magnet`, `current` and `moment`.
-"""
-__all__ = ["magnet", "current", "moment"]  # This is for Sphinx
-#make these subpackages visible in ipython tooltips
-
-from . import magnet
-from . import current
-from . import moment
\ No newline at end of file
diff --git a/magpylib/source/current/__init__.py b/magpylib/source/current/__init__.py
deleted file mode 100644
index a32b1f8a5..000000000
--- a/magpylib/source/current/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-This subpackage provides the current classes that are used for field computation.
-They include the classes `Circular` (a current loop) and `Line` (a line current
-running along given vertices).
-"""
-
-__all__ = ["Circular", "Line"]  # This is for Sphinx
-
-from magpylib._lib.classes.currents import Circular
-from magpylib._lib.classes.currents import Line
diff --git a/magpylib/source/magnet/__init__.py b/magpylib/source/magnet/__init__.py
deleted file mode 100644
index 67f7c5f19..000000000
--- a/magpylib/source/magnet/__init__.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-This subpackage provides the permanent magnet classes that are used for field 
-computation. They include `Box` (cuboid shape), `Cylinder` (cylindrical shape)
-`Sphere` (spherical shape) and `Facet` (triangular surface of magnet body). 
-"""
-
-__all__ = ["Box", "Cylinder", "Sphere", "Facet"]  # This is for Sphinx
-
-from magpylib._lib.classes.magnets import Box
-from magpylib._lib.classes.magnets import Cylinder
-from magpylib._lib.classes.magnets import Sphere
-from magpylib._lib.classes.magnets import Facet
\ No newline at end of file
diff --git a/magpylib/source/moment/__init__.py b/magpylib/source/moment/__init__.py
deleted file mode 100644
index d77e4d034..000000000
--- a/magpylib/source/moment/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-This subpackge includes magnetic moment classes for field computation. Currently
-it includes only the `Dipole` (magnetisches dipol moment) class.
-"""
-
-__all__ = ["Dipole"]  # This is for Sphinx
-
-from magpylib._lib.classes.moments import Dipole
\ No newline at end of file
diff --git a/magpylib/vector/__init__.py b/magpylib/vector/__init__.py
deleted file mode 100644
index 60461d1fe..000000000
--- a/magpylib/vector/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# -------------------------------------------------------------------------------
-# magpylib -- A Python 3 toolbox for working with magnetic fields.
-# Copyright (C) Silicon Austria Labs, https://silicon-austria-labs.com/,
-#               Michael Ortner <magpylib@gmail.com>
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-# PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License along
-# with this program.  If not, see <https://www.gnu.org/licenses/>.
-# The acceptance of the conditions of the GNU Affero General Public License are
-# compulsory for the usage of the software.
-#
-# For contact information, reach out over at <magpylib@gmail.com> or our issues
-# page at https://www.github.com/magpylib/magpylib/issues.
-# -------------------------------------------------------------------------------
-"""
-The vector subpackage includes functions for vectorized computation of the
-magnetic field. Use these functions only when performance is an issue, when 
-doing 10 or more evaluations of similar sources (with different parameters).
-"""
-
-__all__ = ["getBv_magnet", "getBv_current", "getBv_moment"]  # This is for Sphinx
-
-from magpylib._lib.getBvector import getBv_magnet, getBv_current, getBv_moment
-
diff --git a/noxfile.py b/noxfile.py
new file mode 100644
index 000000000..819d36051
--- /dev/null
+++ b/noxfile.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import argparse
+import shutil
+from pathlib import Path
+
+import nox
+
+DIR = Path(__file__).parent.resolve()
+
+nox.needs_version = ">=2024.3.2"
+nox.options.sessions = ["lint", "pylint", "tests"]
+nox.options.default_venv_backend = "uv|virtualenv"
+
+
+@nox.session
+def lint(session: nox.Session) -> None:
+    """
+    Run the linter.
+    """
+    session.install("pre-commit")
+    session.run(
+        "pre-commit", "run", "--all-files", "--show-diff-on-failure", *session.posargs
+    )
+
+
+@nox.session
+def pylint(session: nox.Session) -> None:
+    """
+    Run Pylint.
+    """
+    # This needs to be installed into the package environment, and is slower
+    # than a pre-commit check
+    session.install("--group", "dev", "-e", ".", "pylint>=3.2")
+    session.run("pylint", "magpylib", *session.posargs)
+
+
+@nox.session
+def tests(session: nox.Session) -> None:
+    """
+    Run the unit and regular tests.
+    """
+    session.install("--group", "dev", "-e", ".")
+    session.run("pytest", *session.posargs)
+
+
+@nox.session(reuse_venv=True)
+def docs(session: nox.Session) -> None:
+    """
+    Build the docs. Pass --non-interactive to avoid serving. First positional argument is the target directory.
+    """
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "-b", dest="builder", default="html", help="Build target (default: html)"
+    )
+    parser.add_argument("output", nargs="?", help="Output directory")
+    args, posargs = parser.parse_known_args(session.posargs)
+    serve = args.builder == "html" and session.interactive
+
+    session.install("--group", "dev", "-e", ".", "sphinx-autobuild")
+
+    shared_args = (
+        "-n",  # nitpicky mode
+        "-T",  # full tracebacks
+        f"-b={args.builder}",
+        "docs",
+        args.output or f"docs/_build/{args.builder}",
+        *posargs,
+    )
+
+    if serve:
+        session.run("sphinx-autobuild", "--open-browser", *shared_args)
+    else:
+        session.run("sphinx-build", "--keep-going", *shared_args)
+
+
+@nox.session
+def build_api_docs(session: nox.Session) -> None:
+    """
+    Build (regenerate) API docs.
+    """
+
+    session.install("sphinx")
+    session.run(
+        "sphinx-apidoc",
+        "-o",
+        "docs/api/",
+        "--module-first",
+        "--no-toc",
+        "--force",
+        "src/magpylib",
+    )
+
+
+@nox.session
+def build(session: nox.Session) -> None:
+    """
+    Build an SDist and wheel.
+    """
+
+    build_path = DIR.joinpath("build")
+    if build_path.exists():
+        shutil.rmtree(build_path)
+
+    session.install("build")
+    session.run("python", "-m", "build")
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..36301bf4a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,191 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "magpylib"
+authors = [
+  { name = "Michael Ortner", email = "magpylib@gmail.com" },
+]
+maintainers = [
+    {name = "Alexandre Boisselet", email = "alexabois+magpylib@gmail.com"}
+]
+description = "Python package for computation of magnetic fields of magnets, currents and moments."
+readme = "README.md"
+license = {file = "LICENSE"}
+keywords = ["magnetism", "physics", "analytical", "electromagnetic", "magnetic-field", "B-field"]
+requires-python = ">=3.11"
+classifiers = [
+  "Development Status :: 1 - Planning",
+  "Intended Audience :: Science/Research",
+  "Intended Audience :: Developers",
+  "License :: OSI Approved :: BSD License",
+  "Operating System :: OS Independent",
+  "Programming Language :: Python",
+  "Programming Language :: Python :: 3",
+  "Programming Language :: Python :: 3 :: Only",
+  "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
+  "Topic :: Scientific/Engineering",
+  "Typing :: Typed",
+]
+dynamic = ["version"]
+dependencies = [
+    "numpy>=1.23",
+    "scipy>=1.8",
+    "matplotlib>=3.6",
+    "plotly>=5.16",
+]
+
+[dependency-groups]
+dev = [
+  { include-group = "test" }
+]
+
+docs = [
+  "pydata-sphinx-theme",
+  "sphinx>=7.0,<8.0",
+  "sphinx-design",
+  "sphinx-thebe",
+  "sphinx-favicon",
+  "sphinx-gallery",
+  "sphinx-copybutton",
+  "myst-nb",
+  "pandas",
+  "numpy-stl",
+  "pyvista",
+  "magpylib-material-response",
+  "magpylib-force",
+]
+test = [
+  "pytest>=7.4",
+  "pytest-cov>=3",
+  "pandas",
+  "pyvista",
+  "ipywidgets",  # for plotly FigureWidget
+  "imageio[tifffile,ffmpeg]",
+  "jupyterlab",
+  "anywidget",
+]
+binder = [
+    "jupytext",
+    "jupyterlab>=3.2",
+    "jupyterlab-myst",
+]
+
+
+[project.urls]
+Homepage = "https://github.com/magpylib/magpylib"
+"Bug Tracker" = "https://github.com/magpylib/magpylib/issues"
+Discussions = "https://github.com/magpylib/magpylib/discussions"
+Changelog = "https://github.com/magpylib/magpylib/releases"
+
+
+[tool.hatch]
+version.source = "vcs"
+build.hooks.vcs.version-file = "src/magpylib/_version.py"
+
+[tool.hatch.envs.default]
+installer = "uv"
+features = ["test"]
+scripts.test = "pytest {args}"
+
+
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
+xfail_strict = true
+filterwarnings = [
+  "error",
+]
+log_cli_level = "INFO"
+testpaths = [
+  "tests",
+]
+
+
+[tool.coverage]
+run.source = ["magpylib"]
+report.exclude_also = [
+  '\.\.\.',
+  'if typing.TYPE_CHECKING:',
+]
+
+[tool.mypy]
+files = ["src", "tests"]
+python_version = "3.11"
+warn_unused_configs = true
+strict = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+warn_unreachable = true
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[[tool.mypy.overrides]]
+module = "magpylib.*"
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+
+[tool.ruff]
+
+[tool.ruff.lint]
+extend-select = [
+  "ARG",      # flake8-unused-arguments
+  "B",        # flake8-bugbear
+  "C4",       # flake8-comprehensions
+  "EM",       # flake8-errmsg
+  "EXE",      # flake8-executable
+  "G",        # flake8-logging-format
+  "I",        # isort
+  "ICN",      # flake8-import-conventions
+  "NPY",      # NumPy specific rules
+  "PD",       # pandas-vet
+  "PGH",      # pygrep-hooks
+  "PIE",      # flake8-pie
+  "PL",       # pylint
+  "PT",       # flake8-pytest-style
+  "PTH",      # flake8-use-pathlib
+  "RET",      # flake8-return
+  "RUF",      # Ruff-specific
+  "SIM",      # flake8-simplify
+  "T20",      # flake8-print
+  "UP",       # pyupgrade
+  "YTT",      # flake8-2020
+]
+ignore = [
+  "PLR09",    # Too many <...>
+  "PLR2004",  # Magic value used in comparison
+]
+isort.required-imports = ["from __future__ import annotations"]
+# Uncomment if using a _compat.typing backport
+# typing-modules = ["magpylib._compat.typing"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["T20"]
+"noxfile.py" = ["T20"]
+
+
+[tool.pylint]
+py-version = "3.11"
+ignore-paths = [".*/_version.py"]
+reports.output-format = "colorized"
+similarities.ignore-imports = "yes"
+messages_control.disable = [
+  "design",
+  "fixme",
+  "line-too-long",
+  "missing-module-docstring",
+  "missing-function-docstring",
+  "wrong-import-position",
+  "invalid-name", # TODO - review later
+  "protected-access", # TODO - review later
+  "duplicate-code", # already covered by ruff
+  "unused-argument", # already covered by ruff
+]
+
+[tool.codespell]
+skip = 'src/magpylib/_src/fields/special_el3.py'
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 546d4e335..000000000
--- a/setup.py
+++ /dev/null
@@ -1,90 +0,0 @@
-####
-# This is a basic setup.py structure so we can generate 
-# distributable package information with setuptools.
-# More information: https://packaging.python.org/tutorials/packaging-projects/
-###
-
-###
-# Local install:
-#   Create virtual environment:
-#   $ conda create -n packCondaTest python=3.7.1 anaconda
-#   Activate:
-#   $ conda activate packCondaTest
-#   Generate distribution files (untracked by git):
-#   $ (packCondaTest) python3 setup.py sdist bdist_wheel
-#   Install the generated library for the environment:
-#   $ (packCondaTest) pip install .
-# The library is now in the packCondaTest environment.
-##
-_magPyVersion = "2.3.0-beta"
-
-_SphinxVersion = "1.8.2"
-_name = "magpylib"
-_description = "Free Python3 package to compute magnetic fields."
-_author_email = "magpylib@gmail.com"
-_author = "Michael Ortner"
-_projectUrl = "https://github.com/magpylib/magpylib"
-_release = "beta"
-_license='GNU Affero General Public License v3 or later (AGPLv3+) (AGPL-3.0-or-later)'
-
-import sys
-import os
-import setuptools
-from setuptools.command.install import install
-
-
-with open("README.md", "r") as fh:
-    long_description = fh.read()
-
-class VerifyVersionCommand(install):
-    """Custom command to verify that the git tag matches CircleCI version"""
-    description = 'verify that the git tag matches CircleCI version'
-
-    def run(self):
-        tag = os.getenv('CIRCLE_TAG')
-
-        if tag != _magPyVersion:
-            info = "Git tag: {0} does not match the version of this app: {1}".format(
-                tag, _magPyVersion
-            )
-            sys.exit(info)
-    
-setuptools.setup(
-    name=_name,
-    version=_magPyVersion,
-    author=_author,
-    author_email= _author_email,
-    description=_description,
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    url=_projectUrl,
-    license=_license,
-    packages=setuptools.find_packages(),
-    zip_safe = False, ## Gives the environment files so we can access docs, enables tooltips but may decrease performance
-    install_requires=[
-          'numpy>=1.16',
-          'matplotlib>=3.1',
-      ],
-    classifiers=[
-        'Development Status :: 4 - Beta',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        "Programming Language :: Python :: 3",
-        'Intended Audience :: Developers',
-        'Intended Audience :: Science/Research',
-        'Intended Audience :: Education',
-        'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
-        "Operating System :: OS Independent",
-    ],
-    python_requires='~=3.6',
-    keywords='magnetism physics analytical parallel electromagnetic fields b-field',
-    command_options={
-        'build_sphinx': {
-            'project': ('setup.py', _name),
-            'version': ('setup.py', _SphinxVersion),
-            'release': ('setup.py', _release),
-            'source_dir': ('setup.py', './docs')}},
-    cmdclass={
-    'verify': VerifyVersionCommand,
-    }
-)
diff --git a/src/magpylib/__init__.py b/src/magpylib/__init__.py
new file mode 100644
index 000000000..fc3aa71a7
--- /dev/null
+++ b/src/magpylib/__init__.py
@@ -0,0 +1,39 @@
+"""
+Copyright (c) 2025 Michael Ortner. All rights reserved.
+
+magpylib: Python package for computation of magnetic fields of magnets, currents and moments.
+"""
+
+from __future__ import annotations
+
+from scipy.constants import mu_0
+
+from magpylib import core, current, graphics, magnet, misc
+from magpylib._src.defaults.defaults_classes import default_settings as defaults
+from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS
+from magpylib._src.display.display import show, show_context
+from magpylib._src.fields import getB, getH, getJ, getM
+from magpylib._src.obj_classes.class_Collection import Collection
+from magpylib._src.obj_classes.class_Sensor import Sensor
+
+from ._version import version as __version__
+
+__all__ = [
+    "SUPPORTED_PLOTTING_BACKENDS",
+    "Collection",
+    "Sensor",
+    "__version__",
+    "core",
+    "current",
+    "defaults",
+    "getB",
+    "getH",
+    "getJ",
+    "getM",
+    "graphics",
+    "magnet",
+    "misc",
+    "mu_0",
+    "show",
+    "show_context",
+]
diff --git a/src/magpylib/_src/__init__.py b/src/magpylib/_src/__init__.py
new file mode 100644
index 000000000..d2eb175de
--- /dev/null
+++ b/src/magpylib/_src/__init__.py
@@ -0,0 +1 @@
+"""_src"""
diff --git a/src/magpylib/_src/defaults/__init__.py b/src/magpylib/_src/defaults/__init__.py
new file mode 100644
index 000000000..44cfafc46
--- /dev/null
+++ b/src/magpylib/_src/defaults/__init__.py
@@ -0,0 +1 @@
+"""defaults module"""
diff --git a/src/magpylib/_src/defaults/defaults_classes.py b/src/magpylib/_src/defaults/defaults_classes.py
new file mode 100644
index 000000000..7c7b5cb32
--- /dev/null
+++ b/src/magpylib/_src/defaults/defaults_classes.py
@@ -0,0 +1,277 @@
+from __future__ import annotations
+
+from magpylib._src.defaults.defaults_utility import (
+    SUPPORTED_PLOTTING_BACKENDS,
+    MagicProperties,
+    color_validator,
+    get_defaults_dict,
+    validate_property_class,
+)
+from magpylib._src.style import DisplayStyle
+
+
+class DefaultSettings(MagicProperties):
+    """Library default settings.
+
+    Parameters
+    ----------
+    display: dict or Display
+        `Display` class containing display settings. `('backend', 'animation', 'colorsequence' ...)`
+    """
+
+    def __init__(
+        self,
+        display=None,
+        **kwargs,
+    ):
+        super().__init__(
+            display=display,
+            **kwargs,
+        )
+        self.reset()
+
+    def reset(self):
+        """Resets all nested properties to their hard coded default values"""
+        self.update(get_defaults_dict(), _match_properties=False)
+        return self
+
+    @property
+    def display(self):
+        """`Display` class containing display settings.
+        `('backend', 'animation', 'colorsequence')`"""
+        return self._display
+
+    @display.setter
+    def display(self, val):
+        self._display = validate_property_class(val, "display", Display, self)
+
+
+class Display(MagicProperties):
+    """
+    Defines the properties for the plotting features.
+
+    Properties
+    ----------
+    backend: str, default='matplotlib'
+        Defines the plotting backend to be used by default, if not explicitly set in the `display`
+        function (e.g. 'matplotlib', 'plotly').
+        Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS
+
+    colorsequence: iterable, default=
+            ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A',
+            '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1',
+            '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1',
+            '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038']
+        An iterable of color values used to cycle through for every object displayed.
+        A color may be specified by
+      - a hex string (e.g. '#ff0000')
+      - an rgb/rgba string (e.g. 'rgb(255,0,0)')
+      - an hsl/hsla string (e.g. 'hsl(0,100%,50%)')
+      - an hsv/hsva string (e.g. 'hsv(0,100%,100%)')
+      - a named CSS color
+
+    animation: dict or Animation
+        Defines the animation properties used by the `plotly` plotting backend when `animation=True`
+        in the `show` function.
+
+    autosizefactor: int, default=10
+        Defines at which scale objects like sensors and dipoles are displayed.
+        Specifically `object_size` = `canvas_size` / `AUTOSIZE_FACTOR`.
+
+    styles: dict or DisplayStyle
+        Base class containing display styling properties for all object families.
+    """
+
+    @property
+    def backend(self):
+        """plotting backend to be used by default, if not explicitly set in the `display`
+        function (e.g. 'matplotlib', 'plotly').
+        Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS"""
+        return self._backend
+
+    @backend.setter
+    def backend(self, val):
+        backends = [*SUPPORTED_PLOTTING_BACKENDS, "auto"]
+        assert val is None or val in backends, (
+            f"the `backend` property of {type(self).__name__} must be one of"
+            f"{backends}"
+            f" but received {val!r} instead"
+        )
+        self._backend = val
+
+    @property
+    def colorsequence(self):
+        """An iterable of color values used to cycle through for every object displayed.
+          A color may be specified by
+        - a hex string (e.g. '#ff0000')
+        - an rgb/rgba string (e.g. 'rgb(255,0,0)')
+        - an hsl/hsla string (e.g. 'hsl(0,100%,50%)')
+        - an hsv/hsva string (e.g. 'hsv(0,100%,100%)')
+        - a named CSS color"""
+        return self._colorsequence
+
+    @colorsequence.setter
+    def colorsequence(self, val):
+        if val is not None:
+            name = type(self).__name__
+            try:
+                val = tuple(
+                    color_validator(c, allow_None=False, parent_name=f"{name}")
+                    for c in val
+                )
+            except TypeError as err:
+                msg = (
+                    f"The `colorsequence` property of {name} must be an "
+                    f"iterable of colors but received {val!r} instead"
+                )
+                raise ValueError(msg) from err
+
+        self._colorsequence = val
+
+    @property
+    def animation(self):
+        """Animation properties used by the `plotly` plotting backend when `animation=True`
+        in the `show` function."""
+        return self._animation
+
+    @animation.setter
+    def animation(self, val):
+        self._animation = validate_property_class(val, "animation", Animation, self)
+
+    @property
+    def autosizefactor(self):
+        """Defines at which scale objects like sensors and dipoles are displayed.
+        Specifically `object_size` = `canvas_size` / `AUTOSIZE_FACTOR`."""
+        return self._autosizefactor
+
+    @autosizefactor.setter
+    def autosizefactor(self, val):
+        assert val is None or (isinstance(val, int | float) and val > 0), (
+            f"the `autosizefactor` property of {type(self).__name__} must be a strictly positive"
+            f" number but received {val!r} instead"
+        )
+        self._autosizefactor = val
+
+    @property
+    def style(self):
+        """Base class containing display styling properties for all object families."""
+        return self._style
+
+    @style.setter
+    def style(self, val):
+        self._style = validate_property_class(val, "style", DisplayStyle, self)
+
+
+class Animation(MagicProperties):
+    """
+    Defines the animation properties used by the `plotly` plotting backend when `animation=True`
+    in the `display` function.
+
+    Properties
+    ----------
+    fps: str, default=30
+        Target number of frames to be displayed per second.
+
+    maxfps: str, default=50
+        Maximum number of frames to be displayed per second before downsampling kicks in.
+
+    maxframes: int, default=200
+        Maximum total number of frames to be displayed before downsampling kicks in.
+
+    time: float, default=5
+        Default animation time.
+
+    slider: bool, default = True
+        If True, an interactive slider will be displayed and stay in sync with the animation, will
+        be hidden otherwise.
+
+    output: str, default = None
+        The path where to store the animation. Must end with `.mp4` or `.gif`. If only the suffix
+        is used, the file is only store in a temporary folder and deleted after the animation is
+        done.
+    """
+
+    @property
+    def maxfps(self):
+        """Maximum number of frames to be displayed per second before downsampling kicks in."""
+        return self._maxfps
+
+    @maxfps.setter
+    def maxfps(self, val):
+        assert val is None or (isinstance(val, int) and val > 0), (
+            f"The `maxfps` property of {type(self).__name__} must be a strictly positive"
+            f" integer but received {val!r} instead."
+        )
+        self._maxfps = val
+
+    @property
+    def fps(self):
+        """Target number of frames to be displayed per second."""
+        return self._fps
+
+    @fps.setter
+    def fps(self, val):
+        assert val is None or (isinstance(val, int) and val > 0), (
+            f"The `fps` property of {type(self).__name__} must be a strictly positive"
+            f" integer but received {val!r} instead."
+        )
+        self._fps = val
+
+    @property
+    def maxframes(self):
+        """Maximum total number of frames to be displayed before downsampling kicks in."""
+        return self._maxframes
+
+    @maxframes.setter
+    def maxframes(self, val):
+        assert val is None or (isinstance(val, int) and val > 0), (
+            f"The `maxframes` property of {type(self).__name__} must be a strictly positive"
+            f" integer but received {val!r} instead."
+        )
+        self._maxframes = val
+
+    @property
+    def time(self):
+        """Default animation time."""
+        return self._time
+
+    @time.setter
+    def time(self, val):
+        assert val is None or (isinstance(val, int) and val > 0), (
+            f"The `time` property of {type(self).__name__} must be a strictly positive"
+            f" integer but received {val!r} instead."
+        )
+        self._time = val
+
+    @property
+    def slider(self):
+        """show/hide slider"""
+        return self._slider
+
+    @slider.setter
+    def slider(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `slider` property of {type(self).__name__} must be a either `True` or `False`"
+            f" but received {val!r} instead."
+        )
+        self._slider = val
+
+    @property
+    def output(self):
+        """Animation output type"""
+        return self._output
+
+    @output.setter
+    def output(self, val):
+        if val is not None:
+            val = str(val)
+            valid = val.endswith(("mp4", "gif"))
+            assert val is None or valid, (
+                f"The `output` property of {type(self).__name__} must be a either `mp4` or `gif` "
+                "or a valid path ending with `.mp4` or `.gif`"
+                f" but received {val!r} instead."
+            )
+        self._output = val
+
+
+default_settings = DefaultSettings()
diff --git a/src/magpylib/_src/defaults/defaults_utility.py b/src/magpylib/_src/defaults/defaults_utility.py
new file mode 100644
index 000000000..192f087e5
--- /dev/null
+++ b/src/magpylib/_src/defaults/defaults_utility.py
@@ -0,0 +1,448 @@
+"""utilities for creating property classes"""
+
+# pylint: disable=too-many-branches
+from __future__ import annotations
+
+import collections.abc
+import re
+from copy import deepcopy
+from functools import lru_cache
+
+from matplotlib.colors import CSS4_COLORS as mcolors
+
+from magpylib._src.defaults.defaults_values import DEFAULTS
+
+SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista")
+
+
+ALLOWED_SYMBOLS = (".", "+", "D", "d", "s", "x", "o")
+
+ALLOWED_LINESTYLES = (
+    "solid",
+    "dashed",
+    "dotted",
+    "dashdot",
+    "loosely dotted",
+    "loosely dashdotted",
+    "-",
+    "--",
+    "-.",
+    ".",
+    ":",
+    (0, (1, 1)),
+)
+
+COLORS_SHORT_TO_LONG = {
+    "r": "red",
+    "g": "green",
+    "b": "blue",
+    "y": "yellow",
+    "m": "magenta",
+    "c": "cyan",
+    "k": "black",
+    "w": "white",
+}
+
+
+class _DefaultType:
+    """Special keyword value.
+
+    The instance of this class may be used as the default value assigned to a
+    keyword if no other obvious default (e.g., `None`) is suitable,
+
+    """
+
+    __instance = None
+
+    def __new__(cls):
+        # ensure that only one instance exists
+        if not cls.__instance:
+            cls.__instance = super().__new__(cls)
+        return cls.__instance
+
+    def __repr__(self):  # pragma: no cover
+        return "<default>"
+
+
+_DefaultValue = _DefaultType()
+
+
+def get_defaults_dict(arg=None) -> dict:
+    """returns default dict or sub-dict based on `arg`.
+    (e.g. `get_defaults_dict('display.style')`)
+
+    Returns
+    -------
+    dict
+        default sub dict
+    """
+
+    dict_ = deepcopy(DEFAULTS)
+    if arg is not None:
+        for v in arg.split("."):
+            dict_ = dict_[v]
+    return dict_
+
+
+def update_nested_dict(d, u, same_keys_only=False, replace_None_only=False) -> dict:
+    """updates recursively dictionary 'd' from  dictionary 'u'
+
+    Parameters
+    ----------
+    d : dict
+       dictionary to be updated
+    u : dict
+        dictionary to update with
+    same_keys_only : bool, optional
+        if `True`, only key found in `d` get updated and no new items are created,
+        by default False
+    replace_None_only : bool, optional
+        if `True`, only key/value pair from `d`where `value=None` get updated from `u`,
+        by default False
+
+    Returns
+    -------
+    dict
+        updated dictionary
+    """
+    if not isinstance(d, collections.abc.Mapping):
+        if d is None or not replace_None_only:
+            d = u.copy()
+        return d
+    new_dict = deepcopy(d)
+    for k, v in u.items():
+        if k in new_dict or not same_keys_only:
+            if isinstance(v, collections.abc.Mapping):
+                new_dict[k] = update_nested_dict(
+                    new_dict.get(k, {}),
+                    v,
+                    same_keys_only=same_keys_only,
+                    replace_None_only=replace_None_only,
+                )
+            elif (new_dict.get(k, None) is None or not replace_None_only) and (
+                not same_keys_only or k in new_dict
+            ):
+                new_dict[k] = u[k]
+    return new_dict
+
+
+def magic_to_dict(kwargs, separator="_") -> dict:
+    """decomposes recursively a dictionary with keys with underscores into a nested dictionary
+    example : {'magnet_color':'blue'} -> {'magnet': {'color':'blue'}}
+    see: https://plotly.com/python/creating-and-updating-figures/#magic-underscore-notation
+
+    Parameters
+    ----------
+    kwargs : dict
+        dictionary of keys to be decomposed into a nested dictionary
+
+    separator: str, default='_'
+        defines the separator to apply the magic parsing with
+    Returns
+    -------
+    dict
+        nested dictionary
+    """
+    assert isinstance(kwargs, dict), "kwargs must be a dictionary"
+    assert isinstance(separator, str), "separator must be a string"
+    new_kwargs = {}
+    for k, v in kwargs.items():
+        keys = k.split(separator)
+        if len(keys) == 1:
+            new_kwargs[keys[0]] = v
+        else:
+            val = {separator.join(keys[1:]): v}
+            if keys[0] in new_kwargs and isinstance(new_kwargs[keys[0]], dict):
+                new_kwargs[keys[0]].update(val)
+            else:
+                new_kwargs[keys[0]] = val
+    for k, v in new_kwargs.items():
+        if isinstance(v, dict):
+            new_kwargs[k] = magic_to_dict(v, separator=separator)
+    return new_kwargs
+
+
+def linearize_dict(kwargs, separator=".") -> dict:
+    """linearizes `kwargs` dictionary using the provided `separator
+    Parameters
+    ----------
+    kwargs : dict
+        dictionary of keys linearized into an flat dictionary
+
+    separator: str, default='.'
+        defines the separator to be applied on the final dictionary keys
+
+    Returns
+    -------
+    dict
+        flat dictionary with keys names using a separator
+
+    Examples
+    --------
+    >>> from magpylib._src.defaults.defaults_utility import linearize_dict
+    >>> from pprint import pprint
+    >>> mydict = {
+    ...     'line': {'width': 1, 'style': 'solid', 'color': None},
+    ...     'marker': {'size': 1, 'symbol': 'o', 'color': None}
+    ... }
+    >>> flat_dict = linearize_dict(mydict, separator='.')
+    >>> pprint(flat_dict)
+    {'line.color': None,
+     'line.style': 'solid',
+     'line.width': 1,
+     'marker.color': None,
+     'marker.size': 1,
+     'marker.symbol': 'o'}
+    """
+    assert isinstance(kwargs, dict), "kwargs must be a dictionary"
+    assert isinstance(separator, str), "separator must be a string"
+    dict_ = {}
+    for k, v in kwargs.items():
+        if isinstance(v, dict):
+            d = linearize_dict(v, separator=separator)
+            for key, val in d.items():
+                dict_[f"{k}{separator}{key}"] = val
+        else:
+            dict_[k] = v
+    return dict_
+
+
+@lru_cache(maxsize=1000)
+def color_validator(color_input, allow_None=True, parent_name=""):
+    """validates color inputs based on chosen `backend', allows `None` by default.
+
+    Parameters
+    ----------
+    color_input : str
+        color input as string
+    allow_None : bool, optional
+        if `True` `color_input` can be `None`, by default True
+    parent_name : str, optional
+        name of the parent class of the validator, by default ""
+
+    Returns
+    -------
+    color_input
+        returns input if validation succeeds
+
+    Raises
+    ------
+    ValueError
+        raises ValueError inf validation fails
+    """
+    if allow_None and color_input is None:
+        return color_input
+
+    fail = True
+    # check if greyscale
+    isfloat = True
+    try:
+        float(color_input)
+    except (ValueError, TypeError):
+        isfloat = False
+    if isfloat:
+        color_new = color_input = float(color_input)
+        if 0 <= color_new <= 1:
+            c = int(color_new * 255)
+            color_new = f"#{c:02x}{c:02x}{c:02x}"
+    elif isinstance(color_input, tuple | list):
+        color_new = tuple(color_input)
+        if len(color_new) == 4:  # trim opacity
+            color_new = color_new[:-1]
+        if len(color_new) == 3:
+            # transform matplotlib colors scaled from 0-1 to rgb colors
+            if all(isinstance(c, float) for c in color_new):
+                c = [int(255 * c) for c in color_new]
+                color_new = f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
+            if all(isinstance(c, int) for c in color_new):
+                c = tuple(color_new)
+                color_new = f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
+    else:
+        color_new = color_input
+    if isinstance(color_new, str):
+        color_new = COLORS_SHORT_TO_LONG.get(color_new, color_new)
+        color_new = color_new.replace(" ", "").lower()
+        if color_new.startswith("rgb"):
+            color_new = color_new[4:-1].split(",")
+            try:
+                for i, c in enumerate(color_new):
+                    color_new[i] = int(c)
+            except (ValueError, TypeError):
+                color_new = ""
+            if len(color_new) == 3:
+                c = tuple(color_new)
+                color_new = f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
+        re_hex = re.compile(r"#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})")
+        fail = not re_hex.fullmatch(color_new)
+
+    if fail and str(color_new) not in mcolors:
+        msg = (
+            f"Invalid value of type '{type(color_input)}' "
+            f"received for the color property of {parent_name}"
+            f"\n   Received value: {color_input!r}"
+            f"\n\nThe 'color' property is a color and may be specified as:\n"
+            "    - A hex string (e.g. '#ff0000')\n"
+            "    - A rgb string (e.g. 'rgb(185,204,255)')\n"
+            "    - A rgb tuple (e.g. (120,125,126))\n"
+            "    - A number between 0 and 1 (for grey scale) (e.g. '.5' or .8)\n"
+            f"    - A named CSS color:\n{list(mcolors.keys())}"
+        )
+        raise ValueError(msg)
+    return color_new
+
+
+def validate_property_class(val, name, class_, parent):
+    """validator for sub property"""
+    if isinstance(val, dict):
+        val = class_(**val)
+    elif val is None:
+        val = class_()
+    if not isinstance(val, class_):
+        msg = (
+            f"the `{name}` property of `{type(parent).__name__}` must be an instance \n"
+            f"of `{class_}` or a dictionary with equivalent key/value pairs \n"
+            f"but received {val!r} instead"
+        )
+        raise ValueError(msg)
+    return val
+
+
+def validate_style_keys(style_kwargs):
+    """validates style kwargs based on key up to first underscore.
+    checks in the defaults structures the generally available style keys"""
+    styles_by_family = get_defaults_dict("display.style")
+    valid_keys = {key for v in styles_by_family.values() for key in v}
+    level0_style_keys = {k.split("_")[0]: k for k in style_kwargs}
+    kwargs_diff = set(level0_style_keys).difference(valid_keys)
+    invalid_keys = {level0_style_keys[k] for k in kwargs_diff}
+    if invalid_keys:
+        msg = (
+            f"Following arguments are invalid style properties: `{invalid_keys}`\n"
+            f"\n Available style properties are: `{valid_keys}`"
+        )
+        raise ValueError(msg)
+    return style_kwargs
+
+
+class MagicProperties:
+    (
+        """
+    Base Class to represent only the property attributes defined at initialization, after which the
+    class is frozen. This prevents user to create any attributes that are not defined as properties.
+
+    Raises
+    ------
+    AttributeError
+        raises AttributeError if the object is not a property
+    """
+        """"""
+    )
+
+    __isfrozen = False
+
+    def __init__(self, **kwargs):
+        input_dict = dict.fromkeys(self._property_names_generator())
+        if kwargs:
+            magic_kwargs = magic_to_dict(kwargs)
+            diff = set(magic_kwargs.keys()).difference(set(input_dict.keys()))
+            for attr in diff:
+                msg = (
+                    f"{type(self).__name__} has no property '{attr}'"
+                    f"\n Available properties are: {list(self._property_names_generator())}"
+                )
+                raise AttributeError(msg)
+            input_dict.update(magic_kwargs)
+        for k, v in input_dict.items():
+            setattr(self, k, v)
+        self._freeze()
+
+    def __setattr__(self, key, value):
+        if self.__isfrozen and not hasattr(self, key):
+            msg = (
+                f"{type(self).__name__} has no property '{key}'"
+                f"\n Available properties are: {list(self._property_names_generator())}"
+            )
+            raise AttributeError(msg)
+        object.__setattr__(self, key, value)
+
+    def _freeze(self):
+        self.__isfrozen = True
+
+    def _property_names_generator(self):
+        """returns a generator with class properties only"""
+        return (
+            attr
+            for attr in dir(self)
+            if isinstance(getattr(type(self), attr, None), property)
+        )
+
+    def __repr__(self):
+        params = self._property_names_generator()
+        dict_str = ", ".join(f"{k}={getattr(self, k)!r}" for k in params)
+        return f"{type(self).__name__}({dict_str})"
+
+    def as_dict(self, flatten=False, separator="."):
+        """
+        returns recursively a nested dictionary with all properties objects of the class
+
+        Parameters
+        ----------
+        flatten: bool
+            If `True`, the nested dictionary gets flatten out with provided separator for the
+            dictionary keys
+
+        separator: str
+            the separator to be used when flattening the dictionary. Only applies if
+            `flatten=True`
+        """
+        params = self._property_names_generator()
+        dict_ = {}
+        for k in params:
+            val = getattr(self, k)
+            if hasattr(val, "as_dict"):
+                dict_[k] = val.as_dict()
+            else:
+                dict_[k] = val
+        if flatten:
+            dict_ = linearize_dict(dict_, separator=separator)
+        return dict_
+
+    def update(
+        self, arg=None, _match_properties=True, _replace_None_only=False, **kwargs
+    ):
+        """
+        Updates the class properties with provided arguments, supports magic underscore notation
+
+        Parameters
+        ----------
+
+        _match_properties: bool
+            If `True`, checks if provided properties over keyword arguments are matching the current
+            object properties. An error is raised if a non-matching property is found.
+            If `False`, the `update` method does not raise any error when an argument is not
+            matching a property.
+
+        _replace_None_only:
+            updates matching properties that are equal to `None` (not already been set)
+
+
+        Returns
+        -------
+        self
+        """
+        arg = {} if arg is None else arg.copy()
+        arg = magic_to_dict({**arg, **kwargs})
+        current_dict = self.as_dict()
+        new_dict = update_nested_dict(
+            current_dict,
+            arg,
+            same_keys_only=not _match_properties,
+            replace_None_only=_replace_None_only,
+        )
+        for k, v in new_dict.items():
+            setattr(self, k, v)
+        return self
+
+    def copy(self):
+        """returns a copy of the current class instance"""
+        return deepcopy(self)
diff --git a/src/magpylib/_src/defaults/defaults_values.py b/src/magpylib/_src/defaults/defaults_values.py
new file mode 100644
index 000000000..74d164257
--- /dev/null
+++ b/src/magpylib/_src/defaults/defaults_values.py
@@ -0,0 +1,179 @@
+"""Package level config defaults"""
+
+from __future__ import annotations
+
+DEFAULTS = {
+    "display": {
+        "autosizefactor": 10,
+        "animation": {
+            "fps": 20,
+            "maxfps": 30,
+            "maxframes": 200,
+            "time": 5,
+            "slider": True,
+            "output": None,
+        },
+        "backend": "auto",
+        "colorsequence": (
+            "#2E91E5",
+            "#E15F99",
+            "#1CA71C",
+            "#FB0D0D",
+            "#DA16FF",
+            "#B68100",
+            "#750D86",
+            "#EB663B",
+            "#511CFB",
+            "#00A08B",
+            "#FB00D1",
+            "#FC0080",
+            "#B2828D",
+            "#6C7C32",
+            "#778AAE",
+            "#862A16",
+            "#A777F1",
+            "#620042",
+            "#1616A7",
+            "#DA60CA",
+            "#6C4516",
+            "#0D2A63",
+            "#AF0038",
+            "#222A2A",
+        ),
+        "style": {
+            "base": {
+                "path": {
+                    "line": {"width": 1, "style": "solid", "color": None},
+                    "marker": {"size": 3, "symbol": "o", "color": None},
+                    "show": True,
+                    "frames": None,
+                    "numbering": False,
+                },
+                "description": {"show": True, "text": None},
+                "legend": {"show": True, "text": None},
+                "opacity": 1,
+                "model3d": {"showdefault": True, "data": []},
+                "color": None,
+            },
+            "magnet": {
+                "magnetization": {
+                    "show": True,
+                    "arrow": {
+                        "show": True,
+                        "size": 1,
+                        "sizemode": "scaled",
+                        "offset": 1,
+                        "width": 2,
+                        "style": "solid",
+                        "color": None,
+                    },
+                    "color": {
+                        "north": "#E71111",
+                        "middle": "#DDDDDD",
+                        "south": "#00B050",
+                        "transition": 0.2,
+                        "mode": "tricolor",
+                    },
+                    "mode": "auto",
+                }
+            },
+            "current": {
+                "arrow": {
+                    "show": True,
+                    "size": 1,
+                    "sizemode": "scaled",
+                    "offset": 0.5,
+                    "width": 1,
+                    "style": "solid",
+                    "color": None,
+                },
+                "line": {"show": True, "width": 2, "style": "solid", "color": None},
+            },
+            "sensor": {
+                "size": 1,
+                "sizemode": "scaled",
+                "pixel": {
+                    "size": 1,
+                    "sizemode": "scaled",
+                    "color": None,
+                    "symbol": "o",
+                },
+                "arrows": {
+                    "x": {"color": "red"},
+                    "y": {"color": "green"},
+                    "z": {"color": "blue"},
+                },
+            },
+            "dipole": {"size": 1, "sizemode": "scaled", "pivot": "middle"},
+            "triangle": {
+                "magnetization": {
+                    "show": True,
+                    "arrow": {
+                        "show": True,
+                        "size": 1,
+                        "sizemode": "scaled",
+                        "offset": 1,
+                        "width": 2,
+                        "style": "solid",
+                        "color": None,
+                    },
+                    "color": {
+                        "north": "#E71111",
+                        "middle": "#DDDDDD",
+                        "south": "#00B050",
+                        "transition": 0.2,
+                        "mode": "tricolor",
+                    },
+                    "mode": "auto",
+                },
+                "orientation": {
+                    "show": True,
+                    "size": 1,
+                    "color": "grey",
+                    "offset": 0.9,
+                    "symbol": "arrow3d",
+                },
+            },
+            "triangularmesh": {
+                "orientation": {
+                    "show": False,
+                    "size": 1,
+                    "color": "grey",
+                    "offset": 0.9,
+                    "symbol": "arrow3d",
+                },
+                "mesh": {
+                    "grid": {
+                        "show": False,
+                        "line": {"width": 2, "style": "solid", "color": "black"},
+                        "marker": {"size": 1, "symbol": "o", "color": "black"},
+                    },
+                    "open": {
+                        "show": False,
+                        "line": {"width": 2, "style": "solid", "color": "cyan"},
+                        "marker": {"size": 1, "symbol": "o", "color": "black"},
+                    },
+                    "disconnected": {
+                        "show": False,
+                        "line": {"width": 2, "style": "solid", "color": "black"},
+                        "marker": {"size": 5, "symbol": "o", "color": "black"},
+                        "colorsequence": (
+                            "red",
+                            "blue",
+                            "green",
+                            "cyan",
+                            "magenta",
+                            "yellow",
+                        ),
+                    },
+                    "selfintersecting": {
+                        "show": False,
+                        "line": {"width": 2, "style": "solid", "color": "magenta"},
+                        "marker": {"size": 1, "symbol": "o", "color": "black"},
+                    },
+                },
+            },
+            "markers": {"marker": {"size": 2, "color": "grey", "symbol": "x"}},
+        },
+    },
+}
diff --git a/src/magpylib/_src/display/__init__.py b/src/magpylib/_src/display/__init__.py
new file mode 100644
index 000000000..6572634d4
--- /dev/null
+++ b/src/magpylib/_src/display/__init__.py
@@ -0,0 +1 @@
+"""display package"""
diff --git a/src/magpylib/_src/display/backend_matplotlib.py b/src/magpylib/_src/display/backend_matplotlib.py
new file mode 100644
index 000000000..3e4a7f55d
--- /dev/null
+++ b/src/magpylib/_src/display/backend_matplotlib.py
@@ -0,0 +1,417 @@
+"""matplotlib backend"""
+
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-statements
+# pylint: disable=import-outside-toplevel
+# pylint: disable=wrong-import-position
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import contextlib
+import os
+from collections import Counter
+
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib import patches
+from matplotlib.animation import FuncAnimation
+
+from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor
+
+if os.getenv("MAGPYLIB_MPL_SVG") == "true":  # pragma: no cover
+    from matplotlib_inline.backend_inline import set_matplotlib_formats
+
+    set_matplotlib_formats("svg")
+
+SYMBOLS_TO_MATPLOTLIB = {
+    "circle": "o",
+    "cross": "+",
+    "diamond": "d",
+    "square": "s",
+    "x": "x",
+}
+
+LINE_STYLES_TO_MATPLOTLIB = {
+    "solid": "-",
+    "dash": "--",
+    "dashdot": "-.",
+    "dot": (0, (1, 1)),
+    "longdash": "loosely dotted",
+    "longdashdot": "loosely dashdotted",
+}
+
+SCATTER_KWARGS_LOOKUPS = {
+    "ls": ("line", "dash"),
+    "lw": ("line", "width"),
+    "color": ("line", "color"),
+    "marker": ("marker", "symbol"),
+    "mfc": ("marker", "color"),
+    "mec": ("marker", "color"),
+    "ms": ("marker", "size"),
+}
+
+
+class StripedHandler:
+    """
+    Handler for creating a striped legend key using given color data.
+
+    Parameters
+    ----------
+    color_data : dict
+        Dictionary containing color names as keys and their respective proportions as values.
+
+    Attributes
+    ----------
+    colors : list
+        List of colors extracted from the color_data dictionary.
+    proportions : list
+        Normalized list of proportions extracted from the color_data dictionary.
+    """
+
+    def __init__(self, color_data):
+        total = sum(color_data.values())
+        self.colors = list(color_data.keys())
+        self.proportions = [value / total for value in color_data.values()]
+
+    def legend_artist(self, legend, orig_handle, fontsize, handlebox):  # noqa: ARG002
+        # pylint: disable=unused-argument
+        """Create custom legend key"""
+        x0, y0 = handlebox.xdescent, handlebox.ydescent
+        width, height = handlebox.width, handlebox.height
+        patch_width = width
+        current_position = x0
+
+        for color, proportion in zip(self.colors, self.proportions, strict=False):
+            handlebox.add_artist(
+                patches.Rectangle(
+                    [current_position, y0], patch_width * proportion, height, fc=color
+                )
+            )
+            current_position += patch_width * proportion
+
+
+def generic_trace_to_matplotlib(trace, antialiased=True):
+    """Transform a generic trace into a matplotlib trace"""
+    traces_mpl = []
+    leg_title = trace.get("legendgrouptitle_text", None)
+    showlegend = trace.get("showlegend", True)
+    if trace["type"] == "mesh3d":
+        subtraces = [trace]
+        has_facecolor = trace.get("facecolor", None) is not None
+        if has_facecolor:
+            subtraces = subdivide_mesh_by_facecolor(trace)
+        for ind, subtrace in enumerate(subtraces):
+            x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float)
+            triangles = np.array([subtrace[k] for k in "ijk"]).T
+            tr_mesh = {
+                "constructor": "plot_trisurf",
+                "args": (x, y, z),
+                "kwargs": {
+                    "triangles": triangles,
+                    "alpha": subtrace.get("opacity", None),
+                    "color": subtrace.get("color", None),
+                    "linewidth": 0,
+                    "antialiased": antialiased,
+                },
+            }
+            if showlegend and has_facecolor:
+                tr_mesh["legend_handler"] = StripedHandler(Counter(trace["facecolor"]))
+            if ind != 0:  # hide substrace legends except first
+                tr_mesh["kwargs"]["label"] = "_nolegend_"
+            traces_mpl.append(tr_mesh)
+    elif "scatter" in trace["type"]:
+        props = {
+            k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None))
+            for k, v in SCATTER_KWARGS_LOOKUPS.items()
+        }
+        coords_str = "xyz"
+        if trace["type"] == "scatter":
+            coords_str = "xy"
+            # marker size is proportional to area, not radius like generic
+            props["ms"] = np.pi * props["ms"] ** 2
+        coords = np.array([trace[k] for k in coords_str], dtype=float)
+        if isinstance(props["ms"], list | tuple | np.ndarray):
+            traces_mpl.append(
+                {
+                    "constructor": "scatter",
+                    "args": (*coords,),
+                    "kwargs": {
+                        "s": props["ms"],
+                        "color": props["mec"],
+                        "marker": SYMBOLS_TO_MATPLOTLIB.get(
+                            props["marker"], props["marker"]
+                        ),
+                        "label": None,
+                    },
+                }
+            )
+            props.pop("ms")
+            props.pop("marker")
+        if "ls" in props:
+            props["ls"] = LINE_STYLES_TO_MATPLOTLIB.get(props["ls"], props["ls"])
+        if "marker" in props:
+            props["marker"] = SYMBOLS_TO_MATPLOTLIB.get(
+                props["marker"], props["marker"]
+            )
+        mode = trace.get("mode", None)
+        mode = "markers" if mode is None else mode
+        if "lines" not in mode:
+            props["ls"] = ""
+        if "markers" in mode:
+            if not props.get("marker"):
+                props["marker"] = "o"
+        else:
+            props["marker"] = None
+        if "text" in mode and trace.get("text", False) and len(coords) > 0:
+            txt = trace["text"]
+            txt = [txt] * len(coords[0]) if isinstance(txt, str) else txt
+            for *coords_s, t in zip(*coords, txt, strict=False):
+                traces_mpl.append(
+                    {
+                        "constructor": "text",
+                        "args": (*coords_s, t),
+                    }
+                )
+        traces_mpl.append(
+            {
+                "constructor": "plot",
+                "args": coords,
+                "kwargs": {
+                    **{k: v for k, v in props.items() if v is not None},
+                    "alpha": trace.get("opacity", 1),
+                },
+            }
+        )
+    else:  # pragma: no cover
+        msg = (
+            f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace"
+        )
+        raise ValueError(msg)
+    for tr_mesh in traces_mpl:
+        tr_mesh["row"] = trace.get("row", 1)
+        tr_mesh["col"] = trace.get("col", 1)
+        tr_mesh["kwargs"] = tr_mesh.get("kwargs", {})
+        if tr_mesh["constructor"] != "text":
+            if showlegend:
+                if "label" not in tr_mesh["kwargs"]:
+                    tr_mesh["kwargs"]["label"] = trace.get("name", "")
+                    if leg_title is not None:
+                        tr_mesh["kwargs"]["label"] += f" ({leg_title})"
+            else:
+                tr_mesh["kwargs"]["label"] = "_nolegend"
+    return traces_mpl
+
+
+def extract_axis_from_row_col(fig, row, col):
+    "Return axis from row and col values"
+
+    def geom(ax):
+        return ax.get_subplotspec().get_topmost_subplotspec().get_geometry()
+
+    # get nrows and ncols of fig for first axis
+    rc = geom(fig.axes[0])[:2]
+    # get the axis index based on row first
+    default_ind = rc[0] * (row - 1) + col - 1
+    # get last index of geometry, gives the actual index,
+    # since axis can be added in a different order
+    inds = [geom(ax)[-1] for ax in fig.axes]
+    # retrieve first index that matches
+    ind = inds.index(default_ind)
+    return fig.axes[ind]
+
+
+def process_extra_trace(model):
+    "process extra trace attached to some magpylib object"
+    trace3d = model.copy()
+    kw = trace3d.pop("kwargs_extra")
+    trace3d.update({"row": kw["row"], "col": kw["col"]})
+    kw = {
+        "alpha": kw["opacity"],
+        "color": kw["color"],
+        "label": kw["name"] if kw["showlegend"] else "_nolegend_",
+    }
+    trace3d["kwargs"] = {**kw, **trace3d["kwargs"]}
+    return trace3d
+
+
+def display_matplotlib(
+    data,
+    canvas=None,
+    repeat=False,
+    return_fig=False,
+    canvas_update="auto",
+    return_animation=False,
+    max_rows=None,
+    max_cols=None,
+    subplot_specs=None,
+    antialiased=True,
+    legend_maxitems=20,
+    fig_kwargs=None,
+    show_kwargs=None,
+    **kwargs,  # noqa: ARG001
+):
+    """Display objects and paths graphically using the matplotlib library."""
+    frames = data["frames"]
+    ranges = data["ranges"]
+    labels = data["labels"]
+
+    # only update layout if canvas is not provided
+    fig_kwargs = fig_kwargs if fig_kwargs else {}
+    show_kwargs = show_kwargs if show_kwargs else {}
+    show_kwargs = {**show_kwargs}
+
+    for fr in frames:
+        new_data = []
+        for tr in fr["data"]:
+            new_data.extend(generic_trace_to_matplotlib(tr, antialiased=antialiased))
+        for model in fr["extra_backend_traces"]:
+            new_data.append(process_extra_trace(model))
+        fr["data"] = new_data
+
+    show_canvas = bool(canvas is None)
+    axes = {}
+    if canvas_update:
+        fig_kwargs["dpi"] = fig_kwargs.get("dpi", 80)
+        if fig_kwargs.get("figsize", None) is None:
+            figsize = (8, 8)
+            ratio = subplot_specs.shape[1] / subplot_specs.shape[0]
+            if legend_maxitems != 0:
+                ratio *= 1.5  # extend horizontal ratio if legend is present
+            fig_kwargs["figsize"] = (figsize[0] * ratio, figsize[1])
+    if canvas is None:
+        fig = plt.figure(**{"tight_layout": True, **fig_kwargs})
+    elif isinstance(canvas, mpl.axes.Axes):
+        fig = canvas.get_figure()
+        if max_rows is not None or max_cols is not None:
+            msg = (
+                "Provided canvas is an instance of `matplotlib.axes.Axes` and does not support "
+                "`rows` or `cols` attributes. Use an instance of `matplotlib.figure.Figure` "
+                "instead"
+            )
+            raise ValueError(msg)
+    elif isinstance(canvas, mpl.figure.Figure):
+        fig = canvas
+    else:
+        msg = (
+            "The `canvas` parameter must be one of `[None, matplotlib.axes.Axes, "
+            f"matplotlib.figure.Figure]`. Received type {type(canvas)!r} instead"
+        )
+        raise TypeError(msg)
+    if canvas is not None and canvas_update:
+        fig.set_size_inches(*fig_kwargs["figsize"], forward=True)
+        fig.set_dpi(fig_kwargs["dpi"])
+    if max_rows is None and max_cols is None:
+        if isinstance(canvas, mpl.axes.Axes):
+            axes[(1, 1)] = canvas
+        else:
+            sp_typ = subplot_specs[0, 0]["type"]
+            axes[(1, 1)] = fig.add_subplot(
+                111, projection="3d" if sp_typ == "scene" else None
+            )
+    else:
+        max_rows = max_rows if max_rows is not None else 1
+        max_cols = max_cols if max_cols is not None else 1
+        count = 0
+        for row in range(1, max_rows + 1):
+            for col in range(1, max_cols + 1):
+                subplot_found = True
+                count += 1
+                row_col_num = (row, col)
+                projection = (
+                    "3d" if subplot_specs[row - 1, col - 1]["type"] == "scene" else None
+                )
+                if isinstance(canvas, mpl.figure.Figure):
+                    try:
+                        axes[row_col_num] = extract_axis_from_row_col(fig, row, col)
+                    except (ValueError, IndexError):  # IndexError if axis is not found
+                        subplot_found = False
+                if canvas is None or not subplot_found:
+                    axes[row_col_num] = fig.add_subplot(
+                        max_rows, max_cols, count, projection=projection
+                    )
+                if axes[row_col_num].name == "3d":
+                    axes[row_col_num].set_box_aspect((1, 1, 1))
+
+    def draw_frame(frame_ind):
+        count_with_labels = {}
+        handler_map = {}
+        for tr in frames[frame_ind]["data"]:
+            row_col_num = (tr["row"], tr["col"])
+            ax = axes[row_col_num]
+            constructor = tr["constructor"]
+            args = tr.get("args", ())
+            kwargs = tr.get("kwargs", {})
+            if frame_ind == 0:
+                if row_col_num not in count_with_labels:
+                    count_with_labels[row_col_num] = 0
+                label = kwargs.get("label", "_")
+                if label and not label.startswith("_"):
+                    count_with_labels[row_col_num] += 1
+            trace = getattr(ax, constructor)(*args, **kwargs)
+            if "legend_handler" in tr:
+                handler_map[trace] = tr["legend_handler"]
+            if constructor == "plot_trisurf":
+                # 'Poly3DCollection' object has no attribute '_edgecolors2d'
+                for arg in ("face", "edge"):
+                    color = getattr(trace, f"_{arg}color3d", None)
+                    color = (  # for mpl version <3.3.3
+                        getattr(trace, f"_{arg}colors3d", None)
+                        if color is None
+                        else color
+                    )
+                    setattr(trace, f"_{arg}colors2d", color)
+        for row_col_num, ax in axes.items():
+            count = count_with_labels.get(row_col_num, 0)
+            if ax.name == "3d":
+                if row_col_num in ranges and canvas_update:
+                    ax.set(
+                        **{f"{k}label": labels[row_col_num][k] for k in "xyz"},
+                        **{
+                            f"{k}lim": r
+                            for k, r in zip("xyz", ranges[row_col_num], strict=False)
+                        },
+                    )
+                ax.set_box_aspect(aspect=(1, 1, 1))
+                if 0 < count <= legend_maxitems:
+                    lg_kw = {"bbox_to_anchor": (1.04, 1), "loc": "upper left"}
+                    if handler_map:
+                        lg_kw["handler_map"] = handler_map
+                    with contextlib.suppress(AttributeError):
+                        # see https://github.com/matplotlib/matplotlib/pull/25565
+                        ax.legend(**lg_kw)
+            else:
+                ax.legend(loc="best")
+
+    def animate(ind):  # pragma: no cover
+        for ax in axes.values():
+            ax.clear()
+        draw_frame(ind)
+        return list(axes.values())
+
+    anim = None
+    if len(frames) == 1:
+        draw_frame(0)
+    else:
+        anim = FuncAnimation(
+            fig,
+            animate,
+            frames=range(len(frames)),
+            interval=data["frame_duration"],
+            blit=False,
+            repeat=repeat,
+        )
+
+    out = ()
+    if return_fig:
+        show_canvas = False
+        out += (fig,)
+    if return_animation and len(frames) != 1:
+        show_canvas = False
+        out += (anim,)
+    if show_canvas:
+        plt.show(**show_kwargs)
+
+    if out:
+        return out[0] if len(out) == 1 else out
+    return None
diff --git a/src/magpylib/_src/display/backend_plotly.py b/src/magpylib/_src/display/backend_plotly.py
new file mode 100644
index 000000000..c7a9156ae
--- /dev/null
+++ b/src/magpylib/_src/display/backend_plotly.py
@@ -0,0 +1,371 @@
+"""plotly backend"""
+
+# pylint: disable=C0302
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import inspect
+from functools import cache
+
+import numpy as np
+
+try:
+    import plotly.graph_objects as go
+except ImportError as missing_module:  # pragma: no cover
+    msg = """In order to use the plotly plotting backend, you need to install plotly via pip or conda,
+        see https://github.com/plotly/plotly.py"""
+    raise ModuleNotFoundError(msg) from missing_module
+
+from magpylib._src.defaults.defaults_utility import linearize_dict
+from magpylib._src.display.traces_utility import get_scene_ranges
+
+SYMBOLS_TO_PLOTLY = {
+    ".": "circle",
+    "o": "circle",
+    "+": "cross",
+    "D": "diamond",
+    "d": "diamond",
+    "s": "square",
+    "x": "x",
+}
+
+LINESTYLES_TO_PLOTLY = {
+    "solid": "solid",
+    "-": "solid",
+    "dashed": "dash",
+    "--": "dash",
+    "dashdot": "dashdot",
+    "-.": "dashdot",
+    "dotted": "dot",
+    ".": "dot",
+    ":": "dot",
+    (0, (1, 1)): "dot",
+    "loosely dotted": "longdash",
+    "loosely dashdotted": "longdashdot",
+}
+
+SIZE_FACTORS_TO_PLOTLY = {
+    "line_width": 2.2,
+    "marker_size": 0.7,
+}
+
+
+@cache  # Cache all results
+def match_args(ttype: str):
+    """
+    Return named arguments of a Plotly graph object function.
+
+    Parameters
+    ----------
+    ttype : str
+        Type of Plotly graph object (e.g., 'scatter' for go.Scatter).
+
+    Returns
+    -------
+    set
+        Names of the named arguments of the specified function.
+    """
+    func = getattr(go, ttype.capitalize())
+    sig = inspect.signature(func)
+    params = sig.parameters
+    named_args = [
+        name
+        for name, param in params.items()
+        if param.default != inspect.Parameter.empty
+    ]
+    return set(named_args)
+
+
+def apply_fig_ranges(fig, ranges_rc, labels_rc, apply2d=True):
+    """This is a helper function which applies the ranges properties of the provided `fig` object
+    according to a provided ranges for each subplot. All three space direction will be equal and
+    match the maximum of the ranges needed to display all objects, including their paths.
+
+    Parameters
+    ----------
+    ranges_rc: dict of arrays of dimension=(3,2)
+        min and max graph range
+    labels_rc: dict of dicts
+        contains a dict with 'x', 'y', 'z' keys and respective labels as strings for each subplot
+
+    apply2d: bool, default = True
+        applies fixed range also on 2d traces
+
+    Returns
+    -------
+    None: NoneType
+    """
+    for rc, ranges in ranges_rc.items():
+        row, col = rc
+        labels = labels_rc.get(rc, dict.fromkeys("xyz", ""))
+        kwargs = {
+            **{
+                f"{k}axis": {
+                    "range": ranges[i],
+                    "autorange": False,
+                    "title": labels[k],
+                }
+                for i, k in enumerate("xyz")
+            },
+            "aspectratio": dict.fromkeys("xyz", 1),
+            "aspectmode": "manual",
+            "camera_eye": {"x": 1, "y": -1.5, "z": 1.4},
+        }
+
+        # pylint: disable=protected-access
+        if fig._grid_ref is not None:
+            kwargs.update({"row": row, "col": col})
+        fig.update_scenes(**kwargs)
+    if apply2d:
+        apply_2d_ranges(fig)
+
+
+def apply_2d_ranges(fig, factor=0.05):
+    """Apply Figure ranges of 2d plots"""
+    traces = fig.data
+    ranges = {}
+    for t in traces:
+        for k in "xy":
+            try:
+                ax_str = getattr(t, f"{k}axis")
+                ax_suff = ax_str.replace(k, "")
+                if ax_suff not in ranges:
+                    ranges[ax_suff] = {"x": [], "y": []}
+                vals = getattr(t, k)
+                ranges[ax_suff][k].append([min(vals), max(vals)])
+            except AttributeError:
+                pass
+    for ax, r in ranges.items():
+        for k in "xy":
+            m, M = [np.min(r[k]), np.max(r[k])]
+            getattr(fig.layout, f"{k}axis{ax}").range = [
+                m - (M - m) * factor,
+                M + (M - m) * factor,
+            ]
+
+
+def animate_path(
+    fig,
+    frames,
+    path_indices,
+    frame_duration,
+    animation_slider=False,
+    update_layout=True,
+    rows=None,
+    cols=None,
+):
+    """This is a helper function which attaches plotly frames to the provided `fig` object
+    according to a certain zoom level. All three space direction will be equal and match the
+    maximum of the ranges needed to display all objects, including their paths.
+    """
+    fps = int(1000 / frame_duration)
+
+    play_dict = {
+        "args": [
+            None,
+            {
+                "frame": {"duration": frame_duration},
+                "transition": {"duration": 0},
+                "fromcurrent": True,
+            },
+        ],
+        "label": "Play",
+        "method": "animate",
+    }
+    pause_dict = {
+        "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}],
+        "label": "Pause",
+        "method": "animate",
+    }
+    buttons_dict = {
+        "buttons": [play_dict, pause_dict],
+        "direction": "left",
+        "pad": {"r": 10, "t": 20},
+        "showactive": False,
+        "type": "buttons",
+        "x": 0.1,
+        "xanchor": "right",
+        "y": 0,
+        "yanchor": "top",
+    }
+
+    if animation_slider:
+        sliders_dict = {
+            "active": 0,
+            "yanchor": "top",
+            "font": {"size": 10},
+            "xanchor": "left",
+            "currentvalue": {
+                "prefix": f"Fps={fps}, Path index: ",
+                "visible": True,
+                "xanchor": "right",
+            },
+            "pad": {"b": 10, "t": 10},
+            "len": 0.9,
+            "x": 0.1,
+            "y": 0,
+            "steps": [],
+        }
+        for ind in path_indices:
+            slider_step = {
+                "args": [
+                    [str(ind + 1)],
+                    {"frame": {"duration": 0, "redraw": True}, "mode": "immediate"},
+                ],
+                "label": str(ind + 1),
+                "method": "animate",
+            }
+            sliders_dict["steps"].append(slider_step)
+
+    # update fig
+    fig.frames = frames
+    frame0 = fig.frames[0]
+    title = frame0.layout.title.text
+    fig.add_traces(frame0.data, rows=rows, cols=cols)
+    if update_layout:
+        fig.update_layout(
+            height=None,
+            title=title,
+        )
+    sliders = [sliders_dict] if animation_slider else None
+    fig.update_layout(
+        updatemenus=[*fig.layout.updatemenus, buttons_dict],
+        sliders=[*fig.layout.sliders, *sliders],
+    )
+
+
+def generic_trace_to_plotly(trace):
+    """Transform a generic trace into a plotly trace"""
+    if "scatter" in trace["type"]:
+        if trace.get("line_width", None):
+            trace["line_width"] *= SIZE_FACTORS_TO_PLOTLY["line_width"]
+        dash = trace.get("line_dash", None)
+        if dash is not None:
+            trace["line_dash"] = LINESTYLES_TO_PLOTLY.get(dash, "solid")
+        symb = trace.get("marker_symbol", None)
+        if symb is not None:
+            trace["marker_symbol"] = SYMBOLS_TO_PLOTLY.get(symb, "circle")
+        if "marker_size" in trace:
+            trace["marker_size"] = (
+                np.array(trace["marker_size"], dtype="float")
+                * SIZE_FACTORS_TO_PLOTLY["marker_size"]
+            )
+    return trace
+
+
+def process_extra_trace(model):
+    "process extra trace attached to some magpylib object"
+    ttype = model["constructor"].lower()
+    kwargs = {**model["kwargs_extra"], **model["kwargs"]}
+    trace3d = {"type": ttype, **kwargs}
+    if ttype == "scatter3d":
+        for k in ("marker", "line"):
+            trace3d[f"{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"])
+    elif ttype == "mesh3d":
+        trace3d["showscale"] = trace3d.get("showscale", False)
+        trace3d["color"] = trace3d.get("color", kwargs["color"])
+    # need to match parameters to the constructor
+    # the color parameter is added by default  int the `traces_generic` module but is not
+    # compatible with some traces (e.g. `go.Surface`)
+    allowed_prefs = match_args(ttype)
+    trace3d = {
+        k: v
+        for k, v in trace3d.items()
+        if k in {"row", "col", "type"}
+        or k in allowed_prefs
+        or ("_" in k and k.split("_")[0] in allowed_prefs)
+    }
+    trace3d.update(linearize_dict(trace3d, separator="_"))
+    return trace3d
+
+
+def display_plotly(
+    data,
+    canvas=None,
+    renderer=None,
+    return_fig=False,
+    canvas_update="auto",
+    max_rows=None,
+    max_cols=None,
+    subplot_specs=None,
+    fig_kwargs=None,
+    show_kwargs=None,
+    **kwargs,  # noqa: ARG001
+):
+    """Display objects and paths graphically using the plotly library."""
+
+    fig_kwargs = fig_kwargs if fig_kwargs else {}
+    show_kwargs = show_kwargs if show_kwargs else {}
+    show_kwargs = {"renderer": renderer, **show_kwargs}
+
+    # only update layout if canvas is not provided
+    fig = canvas
+    show_fig = False
+    extra_data = False
+    if fig is None:
+        if not return_fig:
+            show_fig = True
+        fig = go.Figure()
+
+    if not (max_rows is None and max_cols is None) and fig._grid_ref is None:  # pylint: disable=protected-access
+        fig = fig.set_subplots(
+            rows=max_rows,
+            cols=max_cols,
+            specs=subplot_specs.tolist(),
+        )
+
+    frames = data["frames"]
+    for fr in frames:
+        new_data = []
+        for tr in fr["data"]:
+            new_data.append(generic_trace_to_plotly(tr))
+        for model in fr["extra_backend_traces"]:
+            extra_data = True
+            new_data.append(process_extra_trace(model))
+        fr["data"] = new_data
+        fr.pop("extra_backend_traces", None)
+    with fig.batch_update():
+        for frame in frames:
+            rows_list = []
+            cols_list = []
+            for tr in frame["data"]:
+                row = tr.pop("row", None)
+                col = tr.pop("col", None)
+                rows_list.append(row)
+                cols_list.append(col)
+        if max_rows is None and max_cols is None:
+            rows_list = cols_list = None
+        isanimation = len(frames) != 1
+        if not isanimation:
+            fig.add_traces(frames[0]["data"], rows=rows_list, cols=cols_list)
+        else:
+            animation_slider = data["input_kwargs"].get("animation_slider", False)
+            animate_path(
+                fig,
+                frames,
+                data["path_indices"],
+                data["frame_duration"],
+                animation_slider=animation_slider,
+                update_layout=canvas_update,
+                rows=rows_list,
+                cols=cols_list,
+            )
+        if canvas_update:
+            ranges_rc = data["ranges"]
+            if extra_data:
+                ranges_rc = get_scene_ranges(*frames[0]["data"])
+            apply_fig_ranges(
+                fig, ranges_rc, labels_rc=data["labels"], apply2d=isanimation
+            )
+            fig.update_layout(
+                legend_itemsizing="constant",
+                # legend_groupclick="toggleitem",
+            )
+        fig.update(fig_kwargs)
+
+    if return_fig and not show_fig:
+        return fig
+    if show_fig:
+        fig.show(**show_kwargs)
+    return None
diff --git a/src/magpylib/_src/display/backend_pyvista.py b/src/magpylib/_src/display/backend_pyvista.py
new file mode 100644
index 000000000..6b6ddd2fd
--- /dev/null
+++ b/src/magpylib/_src/display/backend_pyvista.py
@@ -0,0 +1,347 @@
+"""pyvista backend"""
+
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-statements
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import contextlib
+import os
+import tempfile
+from functools import lru_cache
+from pathlib import Path
+
+import numpy as np
+
+try:
+    import pyvista as pv
+except ImportError as missing_module:  # pragma: no cover
+    error_msg = """In order to use the pyvista plotting backend, you need to install pyvista via pip or
+        conda, see https://docs.pyvista.org/getting-started/installation.html"""
+    raise ModuleNotFoundError(error_msg) from missing_module
+
+from matplotlib.colors import LinearSegmentedColormap
+from pyvista.plotting.colors import Color  # pylint: disable=import-error
+
+from magpylib._src.utility import open_animation
+
+# from magpylib._src.utility import format_obj_input
+
+SYMBOLS_TO_PYVISTA = {
+    ".": "o",
+    "o": "o",
+    "+": "+",
+    "D": "d",
+    "d": "d",
+    "s": "s",
+    "x": "x",
+    "circle": "o",
+    "cross": "+",
+    "diamond": "d",
+    "square": "s",
+}
+
+LINESTYLES_TO_PYVISTA = {
+    "solid": "-",
+    "-": "-",
+    "dash": "--",
+    "dashed": "--",
+    "--": "--",
+    "dashdot": "-.",
+    "-.": "-.",
+    "dotted": ":",
+    ".": ":",
+    ":": ":",
+    "dot": ":",
+    (0, (1, 1)): ":",
+    "loosely dotted": ":",
+    "loosely dashdotted": "-..",
+    "longdash": ":",
+    "longdashdot": "-..",
+}
+
+
+@lru_cache(maxsize=32)
+def colormap_from_colorscale(colorscale, name="plotly_to_mpl", N=256, gamma=1.0):
+    """Create matplotlib colormap from plotly colorscale"""
+
+    cs_rgb = [(v[0], Color(v[1]).float_rgb) for v in colorscale]
+    cdict = {
+        rgb_col: [
+            (
+                c[0],
+                *[c[1][rgb_ind]] * 2,
+            )
+            for c in cs_rgb
+        ]
+        for rgb_ind, rgb_col in enumerate(("red", "green", "blue"))
+    }
+    return LinearSegmentedColormap(name, cdict, N, gamma)
+
+
+def generic_trace_to_pyvista(trace):
+    """Transform a generic trace into a pyvista trace"""
+    traces_pv = []
+    leg_title = trace.get("legendgrouptitle_text", None)
+    if trace["type"] == "mesh3d":
+        vertices = np.array([trace[k] for k in "xyz"], dtype=float).T
+        faces = np.array([trace[k] for k in "ijk"]).T.flatten()
+        faces = np.insert(faces, range(0, len(faces), 3), 3)
+        colorscale = trace.get("colorscale", None)
+        mesh = pv.PolyData(vertices, faces)
+        facecolor = trace.get("facecolor", None)
+        trace_pv = {
+            "type": "mesh",
+            "mesh": mesh,
+            "color": trace.get("color", None),
+            "scalars": trace.get("intensity", None),
+            "opacity": trace.get("opacity", None),
+        }
+        if facecolor is not None:
+            # pylint: disable=unsupported-assignment-operation
+            mesh.cell_data["colors"] = [
+                Color(c, default_color=(0, 0, 0)).int_rgb for c in facecolor
+            ]
+            trace_pv.update(
+                {
+                    "scalars": "colors",
+                    "rgb": True,
+                    "preference": "cell",
+                }
+            )
+        traces_pv.append(trace_pv)
+        if colorscale is not None:
+            trace_pv["cmap"] = colormap_from_colorscale(colorscale)
+    elif "scatter" in trace["type"]:
+        line = trace.get("line", {})
+        line_color = line.get("color", trace.get("line_color", None))
+        line_width = line.get("width", trace.get("line_width", None))
+        line_width = 1 if line_width is None else line_width
+        line_style = line.get("dash", trace.get("line_dash"))
+        marker = trace.get("marker", {})
+        marker_color = marker.get("color", trace.get("marker_color", None))
+        marker_size = marker.get("size", trace.get("marker_size", None))
+        marker_size = 1 if marker_size is None else marker_size
+        marker_symbol = marker.get("symbol", trace.get("marker_symbol", None))
+        mode = trace.get("mode", None)
+        mode = "markers" if mode is None else mode
+        if trace["type"] == "scatter3d":
+            points = np.array([trace[k] for k in "xyz"], dtype=float).T
+            if "lines" in mode:
+                trace_pv_line = {
+                    "type": "mesh",
+                    "mesh": pv.lines_from_points(points),
+                    "color": line_color,
+                    "line_width": line_width,
+                    "opacity": trace.get("opacity", None),
+                }
+                traces_pv.append(trace_pv_line)
+            if "markers" in mode:
+                trace_pv_marker = {
+                    "type": "mesh",
+                    "mesh": pv.PolyData(points),
+                    "color": marker_color,
+                    "point_size": marker_size,
+                    "opacity": trace.get("opacity", None),
+                }
+                traces_pv.append(trace_pv_marker)
+            if "text" in mode and trace.get("text", False) and len(points) > 0:
+                txt = trace["text"]
+                txt = [txt] * len(points[0]) if isinstance(txt, str) else txt
+                trace_pv_text = {
+                    "type": "point_labels",
+                    "points": points,
+                    "labels": txt,
+                    "always_visible": True,
+                }
+                traces_pv.append(trace_pv_text)
+        elif trace["type"] == "scatter":
+            if "lines" in mode:
+                trace_pv_line = {
+                    "type": "line",
+                    "x": trace["x"],
+                    "y": trace["y"],
+                    "color": line_color,
+                    "width": line_width,
+                    "style": LINESTYLES_TO_PYVISTA.get(line_style, "-"),
+                    "label": trace.get("name", ""),
+                }
+                traces_pv.append(trace_pv_line)
+            if "markers" in mode:
+                trace_pv_marker = {
+                    "type": "scatter",
+                    "x": trace["x"],
+                    "y": trace["y"],
+                    "color": marker_color,
+                    "size": marker_size,
+                    "style": SYMBOLS_TO_PYVISTA.get(marker_symbol, "o"),
+                }
+                marker_size = (
+                    marker_size
+                    if isinstance(marker_size, list | tuple | np.ndarray)
+                    else np.array([marker_size])
+                )
+                for size in np.unique(marker_size):
+                    tr = trace_pv_marker.copy()
+                    mask = marker_size == size
+                    tr = {
+                        **tr,
+                        "x": np.array(tr["x"])[mask],
+                        "y": np.array(tr["y"][mask]),
+                        "size": size,
+                    }
+                    traces_pv.append(tr)
+    else:  # pragma: no cover
+        msg = f"Trace type {trace['type']!r} cannot be transformed into pyvista trace"
+        raise ValueError(msg)
+    showlegend = trace.get("showlegend", False)
+    for tr in traces_pv:
+        tr["row"] = trace.get("row", 1) - 1
+        tr["col"] = trace.get("col", 1) - 1
+        if tr["type"] != "point_labels" and showlegend:
+            showlegend = False  # show only first subtrace
+            if "label" not in tr:
+                tr["label"] = trace.get("name", "")
+            if leg_title is not None:
+                tr["label"] += f" ({leg_title})"
+        if not tr.get("label", ""):
+            tr.pop("label", None)
+    return traces_pv
+
+
+def display_pyvista(
+    data,
+    canvas=None,
+    return_fig=False,
+    canvas_update="auto",
+    jupyter_backend=None,
+    max_rows=None,
+    max_cols=None,
+    subplot_specs=None,
+    repeat=False,
+    legend_maxitems=20,
+    fig_kwargs=None,
+    show_kwargs=None,
+    mp4_quality=5,
+    **kwargs,  # noqa: ARG001
+):
+    """Display objects and paths graphically using the pyvista library."""
+
+    frames = data["frames"]
+
+    fig_kwargs = fig_kwargs if fig_kwargs else {}
+    show_kwargs = show_kwargs if show_kwargs else {}
+    show_kwargs = {**show_kwargs}
+
+    animation = bool(len(frames) > 1)
+    max_rows = max_rows if max_rows is not None else 1
+    max_cols = max_cols if max_cols is not None else 1
+    show_canvas = False
+    if canvas is None:
+        if not return_fig:
+            show_canvas = True  # pragma: no cover
+        canvas = pv.Plotter(
+            shape=(max_rows, max_cols),
+            off_screen=animation,
+            **fig_kwargs,
+        )
+
+    charts = {}
+    jupyter_backend = show_kwargs.pop("jupyter_backend", jupyter_backend)
+    if jupyter_backend is None:
+        jupyter_backend = pv.global_theme.jupyter_backend
+    count_with_labels = {}
+    charts_max_ind = 0
+
+    def draw_frame(frame_ind):
+        nonlocal count_with_labels, charts_max_ind
+        frame = frames[frame_ind]
+        for tr0 in frame["data"]:
+            for tr1 in generic_trace_to_pyvista(tr0):
+                row = tr1.pop("row", 1)
+                col = tr1.pop("col", 1)
+                typ = tr1.pop("type")
+                if frame_ind == 0:
+                    if (row, col) not in count_with_labels:
+                        count_with_labels[(row, col)] = 0
+                    if tr1.get("label", ""):
+                        count_with_labels[(row, col)] += 1
+                canvas.subplot(row, col)
+                if subplot_specs[row, col]["type"] == "scene":
+                    getattr(canvas, f"add_{typ}")(**tr1)
+                else:
+                    if charts.get((row, col), None) is None:
+                        charts_max_ind += 1
+                        charts[(row, col)] = pv.Chart2D()
+                        canvas.add_chart(charts[(row, col)])
+                    getattr(charts[(row, col)], typ)(**tr1)
+            # in pyvista there is no way to set the bounds so we add corners with
+            # a transparent scatter plot to set the ranges and zoom correctly
+            ranges = data["ranges"][row + 1, col + 1]
+            pts = np.array(np.meshgrid(*ranges)).T.reshape(-1, 3)
+            canvas.add_mesh(pv.PolyData(pts), opacity=0)
+            with contextlib.suppress(StopIteration, IndexError):
+                canvas.remove_scalar_bar()
+                # needs to happen in the loop otherwise they cummulate
+                # while the max of 10 is reached and throws a ValueError
+
+        for (row, col), count in count_with_labels.items():
+            canvas.subplot(row, col)
+            # match other backends plotter properties
+            if canvas_update:
+                if callable(canvas.show_axes):
+                    canvas.show_axes()
+                canvas.camera.azimuth = -90
+                canvas.set_background("gray", top="white")
+            if (
+                0 < count <= legend_maxitems
+                and subplot_specs[row, col]["type"] == "scene"
+            ):
+                canvas.add_legend(bcolor=None)
+
+    def run_animation(filename, embed=True):
+        # embed=True, embeds the animation into the notebook page and is necessary when using
+        # temp files
+        nonlocal show_canvas, charts_max_ind, charts
+        suff = Path(filename).suffix
+        if suff == ".gif":
+            loop = 1 if repeat is False else 0 if repeat is True else int(repeat)
+            canvas.open_gif(filename, loop=loop, fps=1000 / data["frame_duration"])
+        elif suff == ".mp4":
+            canvas.open_movie(
+                filename, framerate=1000 / data["frame_duration"], quality=mp4_quality
+            )
+
+        for frame_ind, _ in enumerate(frames):
+            canvas.clear_actors()
+            for ind in range(charts_max_ind):
+                canvas.remove_chart(ind)
+            charts_max_ind = 0
+            charts = {}
+            draw_frame(frame_ind)
+            canvas.write_frame()
+        canvas.close()
+        show_canvas = False
+        open_animation(filename, embed=embed)
+
+    if len(frames) == 1:
+        draw_frame(0)
+    elif animation:
+        animation_output = data["input_kwargs"].get("animation_output", None)
+        animation_output = "gif" if animation_output is None else animation_output
+        if animation_output in ("gif", "mp4"):
+            try:
+                temp = Path(tempfile.gettempdir()) / os.urandom(24).hex()
+                temp = temp.with_suffix(f".{animation_output}")
+                run_animation(temp, embed=True)
+            finally:
+                with contextlib.suppress(FileNotFoundError):  # pragma: no cover
+                    temp.unlink()
+        else:
+            run_animation(animation_output, embed=True)
+
+    if return_fig and not show_canvas:
+        return canvas
+    if show_canvas:
+        canvas.show(jupyter_backend=jupyter_backend, **show_kwargs)  # pragma: no cover
+    return None
diff --git a/src/magpylib/_src/display/display.py b/src/magpylib/_src/display/display.py
new file mode 100644
index 000000000..3a26ac129
--- /dev/null
+++ b/src/magpylib/_src/display/display.py
@@ -0,0 +1,528 @@
+"""Display function codes"""
+
+from __future__ import annotations
+
+import warnings
+from contextlib import contextmanager
+from importlib import import_module
+from typing import ClassVar
+
+from matplotlib.axes import Axes as mplAxes
+from matplotlib.figure import Figure as mplFig
+
+from magpylib._src.defaults.defaults_utility import _DefaultValue, get_defaults_dict
+from magpylib._src.display.traces_generic import MagpyMarkers, get_frames
+from magpylib._src.display.traces_utility import (
+    DEFAULT_ROW_COL_PARAMS,
+    linearize_dict,
+    process_show_input_objs,
+)
+from magpylib._src.input_checks import (
+    check_format_input_backend,
+    check_format_input_vector,
+    check_input_animation,
+    check_input_canvas_update,
+)
+from magpylib._src.utility import check_path_format
+
+disp_args = set(get_defaults_dict("display"))
+
+
+class RegisteredBackend:
+    """Base class for display backends"""
+
+    backends: ClassVar[dict[str, RegisteredBackend]] = {}
+
+    def __init__(
+        self,
+        *,
+        name,
+        show_func,
+        supports_animation,
+        supports_subplots,
+        supports_colorgradient,
+        supports_animation_output,
+    ):
+        self.name = name
+        self.show_func = show_func
+        self.supports = {
+            "animation": supports_animation,
+            "subplots": supports_subplots,
+            "colorgradient": supports_colorgradient,
+            "animation_output": supports_animation_output,
+        }
+        self._register_backend(name)
+
+    def _register_backend(self, name):
+        self.backends[name] = self
+
+    @classmethod
+    def show(
+        cls,
+        *objs,
+        backend,
+        title=None,
+        max_rows=None,
+        max_cols=None,
+        subplot_specs=None,
+        **kwargs,
+    ):
+        """Display function of the current backend"""
+        self = cls.backends[backend]
+        fallback = {
+            "animation": {"animation": False},
+            "subplots": {"row": None, "col": None},
+            "animation_output": {"animation_output": None},
+        }
+        for name, params in fallback.items():
+            condition = not all(kwargs.get(k, v) == v for k, v in params.items())
+            if condition and not self.supports[name]:
+                supported = [k for k, v in self.backends.items() if v.supports[name]]
+                supported_str = (
+                    f"one of {supported!r}"
+                    if len(supported) > 1
+                    else f"{supported[0]!r}"
+                )
+                warnings.warn(
+                    f"The {backend!r} backend does not support {name!r}, "
+                    f"you need to use {supported_str} instead."
+                    f"\nFalling back to: {params}",
+                    stacklevel=2,
+                )
+                kwargs.update(params)
+        display_kwargs = {
+            k: v
+            for k, v in kwargs.items()
+            if any(k.startswith(arg) for arg in disp_args - {"style"})
+        }
+        style_kwargs = {k: v for k, v in kwargs.items() if k.startswith("style")}
+        style_kwargs = linearize_dict(style_kwargs, separator="_")
+        kwargs = {
+            k: v
+            for k, v in kwargs.items()
+            if (k not in display_kwargs and k not in style_kwargs)
+        }
+        backend_kwargs = {
+            k[len(backend) + 1 :]: v
+            for k, v in kwargs.items()
+            if k.startswith(f"{backend.lower()}_")
+        }
+        backend_kwargs = {**kwargs.pop(backend, {}), **backend_kwargs}
+        kwargs = {k: v for k, v in kwargs.items() if not k.startswith(backend)}
+        fig_kwargs = {
+            **kwargs.pop("fig", {}),
+            **{k[4:]: v for k, v in kwargs.items() if k.startswith("fig_")},
+            **backend_kwargs.pop("fig", {}),
+            **{k[4:]: v for k, v in backend_kwargs.items() if k.startswith("fig_")},
+        }
+        show_kwargs = {
+            **kwargs.pop("show", {}),
+            **{k[5:]: v for k, v in kwargs.items() if k.startswith("show_")},
+            **backend_kwargs.pop("show", {}),
+            **{k[5:]: v for k, v in backend_kwargs.items() if k.startswith("show_")},
+        }
+        kwargs = {
+            k: v for k, v in kwargs.items() if not (k.startswith(("fig", "show")))
+        }
+        data = get_frames(
+            objs,
+            supports_colorgradient=self.supports["colorgradient"],
+            backend=backend,
+            title=title,
+            style_kwargs=style_kwargs,
+            **display_kwargs,
+        )
+        return self.show_func(
+            data,
+            max_rows=max_rows,
+            max_cols=max_cols,
+            subplot_specs=subplot_specs,
+            fig_kwargs=fig_kwargs,
+            show_kwargs=show_kwargs,
+            **kwargs,
+        )
+
+
+def get_show_func(backend):
+    """Return the backend show function"""
+    # defer import to show call. Importerror should only fail if unavalaible backend is called
+    return lambda *args, backend=backend, **kwargs: getattr(
+        import_module(f"magpylib._src.display.backend_{backend}"), f"display_{backend}"
+    )(*args, **kwargs)
+
+
+def infer_backend(canvas):
+    """Infers the plotting backend from canvas and environment"""
+    # pylint: disable=import-outside-toplevel
+    backend = "matplotlib"
+    in_notebook = False
+    plotly_available = False
+    try:
+        import plotly  # pylint: disable=unused-import
+
+        from magpylib._src.utility import is_notebook
+
+        plotly_available = True
+        in_notebook = is_notebook()
+        if in_notebook:
+            backend = "plotly"
+    except ImportError:  # pragma: no cover
+        pass
+    if isinstance(canvas, mplAxes | mplFig):
+        backend = "matplotlib"
+    elif plotly_available and isinstance(
+        canvas, plotly.graph_objects.Figure | plotly.graph_objects.FigureWidget
+    ):
+        backend = "plotly"
+    else:
+        try:
+            import pyvista  # pylint: disable=unused-import
+
+            if isinstance(canvas, pyvista.Plotter):
+                backend = "pyvista"
+        except ImportError:  # pragma: no cover
+            pass
+    return backend
+
+
+def _show(
+    *objects,
+    animation=False,
+    markers=None,
+    canvas=None,
+    canvas_update=None,
+    backend=None,
+    **kwargs,
+):
+    """Display objects and paths graphically.
+
+    See `show` function for docstring details.
+    """
+
+    # process input objs
+    objects, obj_list_flat, max_rows, max_cols, subplot_specs = process_show_input_objs(
+        objects,
+        **{k: v for k, v in kwargs.items() if k in DEFAULT_ROW_COL_PARAMS},
+    )
+    kwargs = {k: v for k, v in kwargs.items() if k not in DEFAULT_ROW_COL_PARAMS}
+    canvas_update = check_input_canvas_update(canvas_update, canvas)
+    # test if every individual obj_path is good
+    check_path_format(obj_list_flat)
+
+    # input checks
+    backend = check_format_input_backend(backend)
+    check_input_animation(animation)
+    check_format_input_vector(
+        markers,
+        dims=(2,),
+        shape_m1=3,
+        sig_name="markers",
+        sig_type="array_like of shape (n,3)",
+        allow_None=True,
+    )
+
+    if markers:
+        objects.append({"objects": [MagpyMarkers(*markers)], **DEFAULT_ROW_COL_PARAMS})
+
+    if backend == "auto":
+        backend = infer_backend(canvas)
+
+    return RegisteredBackend.show(
+        *objects,
+        backend=backend,
+        animation=animation,
+        canvas=canvas,
+        canvas_update=canvas_update,
+        subplot_specs=subplot_specs,
+        max_rows=max_rows,
+        max_cols=max_cols,
+        **kwargs,
+    )
+
+
+def show(
+    *objects,
+    # pylint: disable=unused-argument
+    backend=_DefaultValue,
+    canvas=_DefaultValue,
+    animation=_DefaultValue,
+    zoom=_DefaultValue,
+    markers=_DefaultValue,
+    return_fig=_DefaultValue,
+    canvas_update=_DefaultValue,
+    row=_DefaultValue,
+    col=_DefaultValue,
+    output=_DefaultValue,
+    sumup=_DefaultValue,
+    pixel_agg=_DefaultValue,
+    style=_DefaultValue,
+    **kwargs,
+):
+    """Display objects and paths graphically.
+
+    Global graphic styles can be set with kwargs as style dictionary or using
+    style underscore magic.
+
+    Parameters
+    ----------
+    objects: Magpylib objects (sources, collections, sensors)
+        Objects to be displayed.
+
+    backend: string, default=`None`
+        Define plotting backend. Must be one of `['auto', 'matplotlib', 'plotly', 'pyvista']`.
+        If not set, parameter will default to `magpylib.defaults.display.backend` which is
+        `'auto'` by installation default. With `'auto'`, the backend defaults to `'plotly'` if
+        plotly is installed and the function is called in an `IPython` environment, otherwise
+        defaults to `'matplotlib'` which comes installed with magpylib. If the `canvas` is set,
+        the backend defaults to the one corresponding to the canvas object (see canvas parameter).
+
+    canvas: matplotlib.pyplot `AxesSubplot` or plotly `Figure` object, default=`None`
+        Display graphical output on a given canvas:
+        - with matplotlib: `matplotlib.axes.Axes` with `projection=3d.
+        - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`.
+        - with pyvista: `pyvista.Plotter`.
+        By default a new canvas is created and immediately displayed.
+
+    animation: bool or float, default=`False`
+        If `True` and at least one object has a path, the paths are rendered.
+        If input is a positive float, the animation time is set to the given value.
+        This feature is only available for the plotly backend.
+
+    zoom: float, default=`0`
+        Adjust plot zoom-level. When zoom=0 3D-figure boundaries are tight.
+
+    markers: array_like, shape (n,3), default=`None`
+        Display position markers in the global coordinate system.
+
+    return_fig: bool, default=False
+        If True, the function call returns the figure object.
+
+        - with matplotlib: `matplotlib.figure.Figure`.
+        - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`.
+        - with pyvista: `pyvista.Plotter`.
+
+    canvas_update: bool, default="auto".
+        When no canvas is provided, Magpylib creates one and sets the layout to internally defined
+        settings (e.g. camera angle, aspect ratio). If a canvas is provided, no changes to the
+        layout are made. One can however explicitly force a behavior by setting `canvas_update`
+        to True or False.
+
+    row: int or None,
+        If provided specifies the row in which the objects will be displayed.
+
+    col: int or None,
+        If provided specifies the column in which the objects will be displayed.
+
+    output: tuple or string, default="model3d"
+        Can be a string or a tuple of strings specifying the plot output type. By default
+        `output='model3d'` displays the 3D representations of the objects. If output is a tuple of
+        strings it must be a combination of 'B', 'H', 'M' or 'J' and 'x', 'y' and/or 'z'. When
+        having multiple coordinates, the field value is the combined vector length
+        (e.g. `('Bx', 'Hxy', 'Byz')`) 'Bxy' is equivalent to sqrt(|Bx|^2 + |By|^2). A 2D line plot
+        is then represented accordingly if the objects contain at least one source and one sensor.
+
+    sumup: bool, default=True
+        If True, sums the field values of the sources. Applies only if `output` is not `'model3d'`.
+
+    pixel_agg: bool, default="mean"
+        Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+        which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+        Applies only if `output` is not `'model3d'`.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`. Applies to all objects matching the
+        given style properties.
+
+    Returns
+    -------
+    `None` or figure object
+
+    Examples
+    --------
+
+    Display multiple objects, object paths, markers in 3D using Matplotlib or Plotly:
+
+    >>> import magpylib as magpy
+    >>> src = magpy.magnet.Sphere(polarization=(0,0,1), diameter=1)
+    >>> src.move([(0.1*x,0,0) for x in range(50)])
+    Sphere...
+    >>> src.rotate_from_angax(angle=[*range(0,400,10)], axis='z', anchor=0, start=11)
+    Sphere...
+    >>> ts = [-.4,0,.4]
+    >>> sens = magpy.Sensor(position=(0,0,2), pixel=[(x,y,0) for x in ts for y in ts])
+    >>> magpy.show(src, sens) # doctest: +SKIP
+    >>> magpy.show(src, sens, backend='plotly') # doctest: +SKIP
+    >>> # graphic output
+
+    Display output on your own canvas (here a Matplotlib 3d-axes):
+
+    >>> import matplotlib.pyplot as plt
+    >>> import magpylib as magpy
+    >>> my_axis = plt.axes(projection='3d')
+    >>> magnet = magpy.magnet.Cuboid(polarization=(1,1,1), dimension=(1,2,3))
+    >>> sens = magpy.Sensor(position=(0,0,3))
+    >>> magpy.show(magnet, sens, canvas=my_axis, zoom=1)
+    >>> plt.show() # doctest: +SKIP
+    >>> # graphic output
+
+    Use sophisticated figure styling options accessible from defaults, as individual object styles
+    or as global style arguments in display.
+
+    >>> import magpylib as magpy
+    >>> src1 = magpy.magnet.Sphere(position=[(0,0,0), (0,0,3)], diameter=1, polarization=(1,1,1))
+    >>> src2 = magpy.magnet.Sphere(
+    ...     position=[(1,0,0), (1,0,3)],
+    ...     diameter=1,
+    ...     polarization=(1,1,1),
+    ...     style_path_show=False
+    ... )
+    >>> magpy.defaults.display.style.magnet.magnetization.size = 2
+    >>> src1.style.magnetization.size = 1
+    >>> magpy.show(src1, src2, style_color='r') # doctest: +SKIP
+    >>> # graphic output
+
+    Use a context manager to jointly animate 3d and 2d subplots
+
+    >>> import magpylib as magpy
+    >>> import numpy as np
+    >>> import plotly.graph_objects as go
+    >>> path_len = 40
+    >>> sensor = magpy.Sensor()
+    >>> cyl1 = magpy.magnet.Cylinder(
+    ...    polarization=(.1, 0, 0),
+    ...    dimension=(1, 2),
+    ...    position=(4, 0, 0),
+    ...    style_label="Cylinder1",
+    ... )
+    >>> sensor.move(np.linspace((0, 0, -3), (0, 0, 3), path_len), start=0)
+    Sensor(id=...)
+    >>> cyl1.rotate_from_angax(angle=np.linspace(0, 300, path_len), start=0, axis="z", anchor=0)
+    Cylinder(id=...)
+    >>> cyl2 = cyl1.copy().move((0, 0, 5))
+    >>> fig = go.Figure()
+    >>> with magpy.show_context(cyl1, cyl2, sensor, canvas=fig, backend="plotly", animation=True):
+    ...    magpy.show(col=1, output="model3d")
+    ...    magpy.show(col=2, output="Bxy", sumup=True)
+    ...    magpy.show(col=3, output="Bz", sumup=False)
+    >>> fig.show() # doctest: +SKIP
+    >>> # graphic output
+    """
+    kwargs.update(
+        {
+            k: v
+            for k, v in locals().items()
+            if v is not _DefaultValue and k not in ("objects", "kwargs")
+        }
+    )
+    if ctx.isrunning:
+        rco = {k: v for k, v in kwargs.items() if k in DEFAULT_ROW_COL_PARAMS}
+        ctx.kwargs.update(
+            {k: v for k, v in kwargs.items() if k not in DEFAULT_ROW_COL_PARAMS}
+        )
+        ctx_objects = tuple({**o, **rco} for o in ctx.objects_from_ctx)
+        objects, *_ = process_show_input_objs(ctx_objects + objects, **rco)
+        ctx.objects += tuple(objects)
+        return None
+    return _show(*objects, **kwargs)
+
+
+@contextmanager
+def show_context(
+    *objects,
+    # pylint: disable=unused-argument
+    backend=_DefaultValue,
+    canvas=_DefaultValue,
+    animation=_DefaultValue,
+    zoom=_DefaultValue,
+    markers=_DefaultValue,
+    return_fig=_DefaultValue,
+    canvas_update=_DefaultValue,
+    row=_DefaultValue,
+    col=_DefaultValue,
+    output=_DefaultValue,
+    sumup=_DefaultValue,
+    pixel_agg=_DefaultValue,
+    style=_DefaultValue,
+    **kwargs,
+):
+    """Context manager to temporarily set display settings in the `with` statement context.
+
+    You need to invoke as ``show_context(pattern1=value1, pattern2=value2)``.
+
+    See the `magpylib.show` docstrings for the parameter definitions.
+    """
+    # pylint: disable=protected-access
+    kwargs.update(
+        {
+            k: v
+            for k, v in locals().items()
+            if v is not _DefaultValue and k not in ("objects", "kwargs")
+        }
+    )
+    try:
+        ctx.isrunning = True
+        rco = {k: v for k, v in kwargs.items() if k in DEFAULT_ROW_COL_PARAMS}
+        objects, *_ = process_show_input_objs(objects, **rco)
+        ctx.objects_from_ctx += tuple(objects)
+        ctx.kwargs.update(
+            {k: v for k, v in kwargs.items() if k not in DEFAULT_ROW_COL_PARAMS}
+        )
+        yield ctx
+        ctx.show_return_value = _show(*ctx.objects, **ctx.kwargs)
+    finally:
+        ctx.reset(reset_show_return_value=False)
+
+
+class DisplayContext:
+    """Display context class"""
+
+    show = staticmethod(show)
+
+    def __init__(self, isrunning=False):
+        self.isrunning = isrunning
+        self.objects = ()
+        self.objects_from_ctx = ()
+        self.kwargs = {}
+        self.show_return_value = None
+
+    def reset(self, reset_show_return_value=True):
+        """Reset display context"""
+        self.isrunning = False
+        self.objects = ()
+        self.objects_from_ctx = ()
+        self.kwargs = {}
+        if reset_show_return_value:
+            self.show_return_value = None
+
+
+ctx = DisplayContext()
+
+
+RegisteredBackend(
+    name="matplotlib",
+    show_func=get_show_func("matplotlib"),
+    supports_animation=True,
+    supports_subplots=True,
+    supports_colorgradient=False,
+    supports_animation_output=False,
+)
+
+
+RegisteredBackend(
+    name="plotly",
+    show_func=get_show_func("plotly"),
+    supports_animation=True,
+    supports_subplots=True,
+    supports_colorgradient=True,
+    supports_animation_output=False,
+)
+
+RegisteredBackend(
+    name="pyvista",
+    show_func=get_show_func("pyvista"),
+    supports_animation=True,
+    supports_subplots=True,
+    supports_colorgradient=True,
+    supports_animation_output=True,
+)
diff --git a/src/magpylib/_src/display/sensor_mesh.py b/src/magpylib/_src/display/sensor_mesh.py
new file mode 100644
index 000000000..04495bd5b
--- /dev/null
+++ b/src/magpylib/_src/display/sensor_mesh.py
@@ -0,0 +1,177 @@
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial.transform import Rotation as RotScipy
+
+
+def _get_default_trace():
+    # fmt: off
+    return {
+        "type": "mesh3d",
+        "i": np.array([75, 64,  2, 75, 76, 65, 65, 64,  2,  0,  1,  0, 84, 86, 86, 90, 90, 92,
+                    92, 91, 91, 87, 87, 85, 85, 83, 83, 82, 82, 84, 94, 86, 86, 84, 84, 82,
+                    82, 83, 83, 85, 85, 87, 87, 87, 91, 91, 92, 92, 90, 90, 94, 95, 88, 78,
+                    79, 81, 80, 93, 96, 89, 77, 70, 72, 72, 74, 74, 73, 73, 50, 52, 52, 44,
+                    44, 32, 32, 22, 22, 14, 14, 20, 20, 30, 30, 41, 41, 50, 57, 52, 52, 50,
+                    50, 41, 41, 30, 30, 20, 20, 14, 14, 14, 22, 22, 32, 32, 44, 44, 57, 11,
+                    4, 12, 58, 62,  8,  7, 39, 61, 42, 51, 43, 43, 31, 31, 21, 21, 48, 54,
+                    54, 47, 47, 37, 37, 25, 25, 17, 17, 18, 18, 26, 26, 38, 38, 48, 59, 54,
+                    54, 48, 48, 38, 38, 26, 26, 18, 18, 17, 17, 17, 25, 25, 37, 37, 47, 47,
+                    59, 27,  5, 10, 56, 60,  6,  9, 55, 63, 28, 53, 45, 45, 35, 35, 23, 23],
+                dtype="int64"),
+        "j": np.array([76, 76,  3,  3,  3,  3,  1,  1, 75, 75,  3,  3, 70, 70, 72, 72, 74, 74,
+                    73, 73, 71, 71, 69, 69, 67, 67, 66, 66, 68, 68, 89, 89, 81, 81, 79, 79,
+                    77, 77, 78, 78, 80, 80, 88, 93, 93, 95, 95, 96, 96, 94, 97, 97, 97, 97,
+                    97, 97, 97, 97, 97, 97, 97, 68, 68, 66, 66, 67, 67, 69, 51, 51, 43, 43,
+                    31, 31, 21, 21, 13, 13, 19, 19, 29, 29, 40, 40, 49, 49, 61, 61, 62, 62,
+                    58, 58, 42, 42, 12, 12,  8,  8,  4,  7,  7, 11, 11, 39, 39, 57, 34, 34,
+                    34, 34, 34, 34, 34, 34, 34, 34, 34, 49, 49, 40, 40, 29, 29, 19, 53, 53,
+                    45, 45, 35, 35, 23, 23, 15, 15, 16, 16, 24, 24, 36, 36, 46, 46, 63, 63,
+                    60, 60, 56, 56, 28, 28, 10, 10,  6,  6,  5,  9,  9, 27, 27, 55, 55, 59,
+                    33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 33, 46, 46, 36, 36, 24, 24, 16],
+                dtype="int64"),
+        "k": np.array([64, 65, 75, 76, 65,  1, 64,  0,  0, 64,  0,  2, 86, 72, 90, 74, 92, 73,
+                    91, 71, 87, 69, 85, 67, 83, 66, 82, 68, 84, 70, 86, 81, 84, 79, 82, 77,
+                    83, 78, 85, 80, 87, 88, 93, 91, 95, 92, 96, 90, 94, 86, 89, 96, 93, 80,
+                    77, 79, 88, 95, 94, 81, 78, 72, 66, 74, 67, 73, 69, 71, 52, 43, 44, 31,
+                    32, 21, 22, 13, 14, 19, 20, 29, 30, 40, 41, 49, 50, 51, 52, 62, 50, 58,
+                    41, 42, 30, 12, 20,  8, 14,  4,  7, 22, 11, 32, 39, 44, 57, 52, 61, 39,
+                    7,  8, 42, 58,  4, 11, 57, 62, 12, 43, 40, 31, 29, 21, 19, 13, 54, 45,
+                    47, 35, 37, 23, 25, 15, 17, 16, 18, 24, 26, 36, 38, 46, 48, 53, 54, 60,
+                    48, 56, 38, 28, 26, 10, 18,  6, 17,  5,  9, 25, 27, 37, 55, 47, 59, 54,
+                    63, 55,  9,  6, 28, 56,  5, 27, 59, 60, 10, 45, 36, 35, 24, 23, 16, 15],
+                dtype="int64"),
+        "x": np.array([-5.00000000e-01, -5.00000000e-01, -5.00000000e-01, -5.00000000e-01,
+                    -2.99849272e-01, -2.87847906e-01, -2.87847906e-01, -2.57389992e-01,
+                    -2.47108519e-01, -1.96458220e-01, -1.96458220e-01, -1.33211225e-01,
+                    -1.15912557e-01, -9.99495536e-02, -9.99495536e-02, -9.39692631e-02,
+                    -9.39692631e-02, -9.39692631e-02, -9.39692631e-02, -7.86073282e-02,
+                    -7.86073282e-02, -7.45242685e-02, -7.45242685e-02, -5.00000007e-02,
+                    -5.00000007e-02, -5.00000007e-02, -5.00000007e-02, -4.26944532e-02,
+                    -4.26944532e-02, -2.04838570e-02, -2.04838570e-02, -1.42282564e-02,
+                    -1.42282564e-02, -2.08166817e-16, -1.91079873e-16,  1.73648186e-02,
+                    1.73648186e-02,  1.73648186e-02,  1.73648186e-02,  3.32611799e-02,
+                    4.72242348e-02,  4.72242348e-02,  5.20848148e-02,  5.27253151e-02,
+                    5.27253151e-02,  7.66044408e-02,  7.66044408e-02,  7.66044408e-02,
+                    7.66044408e-02,  9.28355828e-02,  9.28355828e-02,  9.50081274e-02,
+                    9.50081274e-02,  9.99999940e-02,  9.99999940e-02,  1.24624498e-01,
+                    1.24624498e-01,  1.89173400e-01,  2.03545630e-01,  2.52376080e-01,
+                    2.52376080e-01,  2.85024375e-01,  2.90382177e-01,  2.99999982e-01,
+                    5.00000000e-01,  5.00000000e-01,  5.00000000e-01,  5.00000000e-01,
+                    5.00000000e-01,  5.00000000e-01,  5.00000000e-01,  5.00000000e-01,
+                    5.00000000e-01,  5.00000000e-01,  5.00000000e-01,  5.00000000e-01,
+                    5.00000000e-01,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  2.00000000e+00]),
+        "y": np.array([-5.00000000e-01,  5.00000000e-01, -5.00000000e-01,  5.00000000e-01,
+                    1.48038471e+00, -8.45197663e-02,  8.45197663e-02,  1.48038471e+00,
+                    1.48038471e+00, -2.26724878e-01,  2.26724878e-01,  1.48038471e+00,
+                    1.48038471e+00,  5.00000000e-01,  1.48038471e+00, -3.42020132e-02,
+                    3.42020132e-02, -3.42020132e-02,  3.42020132e-02,  5.00000000e-01,
+                    1.48038471e+00,  5.00000000e-01,  1.48038471e+00, -8.66025388e-02,
+                    8.66025388e-02, -8.66025388e-02,  8.66025388e-02, -2.96946436e-01,
+                    2.96946436e-01,  5.00000000e-01,  1.48038471e+00,  5.00000000e-01,
+                    1.48038471e+00,  0.00000000e+00,  2.00000000e+00, -9.84807760e-02,
+                    9.84807760e-02, -9.84807760e-02,  9.84807760e-02,  1.48038471e+00,
+                    5.00000000e-01,  1.48038471e+00,  1.48038471e+00,  5.00000000e-01,
+                    1.48038471e+00, -6.42787591e-02,  6.42787591e-02, -6.42787591e-02,
+                    6.42787591e-02,  5.00000000e-01,  1.48038471e+00,  5.00000000e-01,
+                    1.48038471e+00,  0.00000000e+00,  0.00000000e+00, -2.72889614e-01,
+                    2.72889614e-01,  1.48038471e+00,  1.48038471e+00, -1.62192255e-01,
+                    1.62192255e-01,  1.48038471e+00,  1.48038471e+00,  0.00000000e+00,
+                    -5.00000000e-01,  5.00000000e-01, -3.29800062e-02,  3.54182646e-02,
+                    -8.59465674e-02,  8.72439370e-02, -9.86977741e-02,  9.82472003e-02,
+                    -6.52671903e-02,  6.32795095e-02, -1.29736937e-03, -5.00000000e-01,
+                    5.00000000e-01, -5.62742725e-03,  1.57429606e-01, -1.66897804e-01,
+                    2.70503879e-01, -2.75179297e-01, -3.29800062e-02,  3.54182646e-02,
+                    -8.59465674e-02,  8.72439370e-02, -9.86977741e-02,  9.82472003e-02,
+                    2.97695041e-01, -2.96093315e-01, -6.52671903e-02,  6.32795095e-02,
+                    -1.29736937e-03,  2.30370149e-01, -2.22999811e-01,  8.99043754e-02,
+                    -7.91054145e-02,  1.98500464e-16]),
+        "z": np.array([-5.00000000e-01, -5.00000000e-01,  5.00000000e-01,  5.00000000e-01,
+                    -9.50860139e-03,  1.48038471e+00,  1.48038471e+00,  1.54111609e-01,
+                    -1.70109898e-01,  1.48038471e+00,  1.48038471e+00,  2.68802464e-01,
+                    -2.76702493e-01,  3.17605096e-03,  3.17605096e-03,  5.00000000e-01,
+                    5.00000000e-01,  1.48038471e+00,  1.48038471e+00, -6.18133359e-02,
+                    -6.18133359e-02,  6.66793287e-02,  6.66793287e-02,  5.00000000e-01,
+                    5.00000000e-01,  1.48038471e+00,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00, -9.78795737e-02, -9.78795737e-02,  9.89826098e-02,
+                    9.89826098e-02,  2.00000000e+00, -6.27497823e-17,  5.00000000e-01,
+                    5.00000000e-01,  1.48038471e+00,  1.48038471e+00,  2.98150450e-01,
+                    -8.81468803e-02, -8.81468803e-02, -2.95444012e-01,  8.49708244e-02,
+                    8.49708244e-02,  5.00000000e-01,  5.00000000e-01,  1.48038471e+00,
+                    1.48038471e+00, -3.71692739e-02, -3.71692739e-02,  3.12002450e-02,
+                    3.12002450e-02,  5.00000000e-01,  1.48038471e+00,  1.48038471e+00,
+                    1.48038471e+00,  2.32837781e-01, -2.20384151e-01,  1.48038471e+00,
+                    1.48038471e+00,  9.36007351e-02, -7.53538683e-02,  1.48038471e+00,
+                    -5.00000000e-01, -5.00000000e-01, -9.44050848e-02, -9.35176238e-02,
+                    -5.11193462e-02, -4.88722362e-02,  1.60856955e-02,  1.86410155e-02,
+                    7.57640675e-02,  7.74319321e-02,  9.99915898e-02,  5.00000000e-01,
+                    5.00000000e-01, -2.99947202e-01, -2.55374074e-01, -2.49289244e-01,
+                    -1.29721463e-01, -1.19483687e-01, -9.44050848e-02, -9.35176238e-02,
+                    -5.11193462e-02, -4.88722362e-02,  1.60856955e-02,  1.86410155e-02,
+                    3.71167921e-02,  4.82570902e-02,  7.57640675e-02,  7.74319321e-02,
+                    9.99915898e-02,  1.92170724e-01,  2.00676590e-01,  2.86211818e-01,
+                    2.89382666e-01, -3.23514738e-17]),
+    }
+    # fmt: on
+
+
+def get_sensor_mesh(
+    x_color="red",
+    y_color="green",
+    z_color="blue",
+    center_color="grey",
+    x_show=True,
+    y_show=True,
+    z_show=True,
+    center_show=True,
+    colorize_tails=True,
+    handedness="right",
+):
+    """
+    returns a plotly mesh3d dictionary of a x,y,z arrows oriented in space accordingly
+    and  colored respectively in red,green,blue with a central cube of size 1
+    """
+    x_color_tail = x_color
+    y_color_tail = y_color
+    z_color_tail = z_color
+    if colorize_tails:
+        x_color_tail = center_color
+        y_color_tail = center_color
+        z_color_tail = center_color
+    N, N2 = 56, 18
+    trace = _get_default_trace()
+    if handedness == "left":
+        pts = np.array([trace[k] for k in "xyz"]).T
+        rot = RotScipy.from_euler("y", -90, degrees=True)
+        x, y, z = rot.apply(pts).T
+        trace.update(x=x, y=y, z=z)
+        x_color, z_color = z_color, x_color
+        x_color_tail, z_color_tail = z_color_tail, x_color_tail
+        x_show, z_show = z_show, x_show
+
+    trace["facecolor"] = np.concatenate(
+        [
+            [center_color] * 12,
+            [x_color_tail] * (N2),
+            [x_color] * (N - N2),
+            [y_color_tail] * (N2),
+            [y_color] * (N - N2),
+            [z_color_tail] * (N2),
+            [z_color] * (N - N2),
+        ]
+    )
+    indices = ((0, 12), (12, 68), (68, 124), (124, 180))
+    show = (center_show, x_show, y_show, z_show)
+    for k in ("i", "j", "k", "facecolor"):
+        t = []
+        for i, s in zip(indices, show, strict=False):
+            if s:
+                t.extend(trace[k][i[0] : i[1]])
+        trace[k] = np.array(t)
+    return trace
diff --git a/src/magpylib/_src/display/traces_base.py b/src/magpylib/_src/display/traces_base.py
new file mode 100644
index 000000000..832d37fb3
--- /dev/null
+++ b/src/magpylib/_src/display/traces_base.py
@@ -0,0 +1,669 @@
+"""base traces building functions"""
+
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+from functools import partial
+
+import numpy as np
+from scipy.spatial import ConvexHull  # pylint: disable=no-name-in-module
+
+from magpylib._src.display.traces_utility import merge_mesh3d, place_and_orient_model3d
+from magpylib._src.fields.field_BH_tetrahedron import check_chirality
+
+
+def base_validator(name, value, conditions):
+    """Validates value based on dictionary of conditions"""
+
+    msg = f"""Input {name} must be one of `{tuple(conditions.keys())},`
+received {value!r} instead.
+"""
+    assert value in conditions, msg
+    return conditions[value]
+
+
+validate_pivot = partial(base_validator, "pivot")
+
+
+def get_model(trace, *, backend, show, scale, kwargs):
+    """Returns model3d dict depending on backend"""
+
+    model = {
+        "constructor": "Mesh3d",
+        "kwargs": trace,
+        "args": (),
+        "show": show,
+        "scale": scale,
+    }
+    if backend == "matplotlib":
+        x, y, z, i, j, k = (trace[k] for k in "xyzijk")
+        triangles = np.array([i, j, k]).T
+        model.update(
+            constructor="plot_trisurf", args=(x, y, z), kwargs={"triangles": triangles}
+        )
+    model["kwargs"].update(kwargs)
+    if backend == "plotly-dict":
+        model = {"type": "mesh3d", **model["kwargs"]}
+    else:
+        model["backend"] = backend
+        model["kwargs"].pop("type", None)
+    return model
+
+
+def make_Cuboid(
+    backend="generic",
+    dimension=(1.0, 1.0, 1.0),
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a cuboid in dictionary form, based on
+    the given dimension. The zero position is in the barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    dimension : 3-tuple, default=(1,1,1)
+        Length of the cuboid sides `x,y,z`.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy `Rotation` object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    dimension = np.array(dimension, dtype=float)
+    trace = {
+        "i": np.array([7, 0, 0, 0, 4, 4, 2, 6, 4, 0, 3, 7]),
+        "j": np.array([0, 7, 1, 2, 6, 7, 1, 2, 5, 5, 2, 2]),
+        "k": np.array([3, 4, 2, 3, 5, 6, 5, 5, 0, 1, 7, 6]),
+        "x": np.array([-1, -1, 1, 1, -1, -1, 1, 1]) * 0.5 * dimension[0],
+        "y": np.array([-1, 1, 1, -1, -1, 1, 1, -1]) * 0.5 * dimension[1],
+        "z": np.array([-1, -1, -1, -1, 1, 1, 1, 1]) * 0.5 * dimension[2],
+    }
+
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_Prism(
+    backend="generic",
+    base=3,
+    diameter=1.0,
+    height=1.0,
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a prism in dictionary form, based on
+    number of vertices of the base, diameter and height. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    base : int, default=6
+        Number of vertices of the base in the xy-plane.
+
+    diameter : float, default=1
+        Diameter dimension inscribing the base.
+
+    height : float, default=1
+        Prism height in the z-direction.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    N = base
+    t = np.linspace(0, 2 * np.pi, N, endpoint=False)
+    c1 = np.array([1 * np.cos(t), 1 * np.sin(t), t * 0 - 1]) * 0.5
+    c2 = np.array([1 * np.cos(t), 1 * np.sin(t), t * 0 + 1]) * 0.5
+    c3 = np.array([[0, 0], [0, 0], [-1, 1]]) * 0.5
+    c = np.concatenate([c1, c2, c3], axis=1)
+    c = c.T * np.array([diameter, diameter, height])
+    i1 = np.arange(N)
+    j1 = i1 + 1
+    j1[-1] = 0
+    k1 = i1 + N
+
+    i2 = i1 + N
+    j2 = j1 + N
+    j2[-1] = N
+    k2 = i1 + 1
+    k2[-1] = 0
+
+    i3 = i1
+    j3 = j1
+    k3 = i1 * 0 + 2 * N
+
+    i4 = i2
+    j4 = j2
+    k4 = k3 + 1
+
+    # k2&j2 and k3&j3 inverted because of face orientation
+    i = np.concatenate([i1, i2, i3, i4])
+    j = np.concatenate([j1, k2, k3, j4])
+    k = np.concatenate([k1, j2, j3, k4])
+
+    x, y, z = c.T
+    trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k}
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_Ellipsoid(
+    backend="generic",
+    dimension=(1.0, 1.0, 1.0),
+    vert=15,
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for an ellipsoid in dictionary form, based
+    on number of vertices of the circumference, and the dimension. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    dimension : tuple, default=(1.0, 1.0, 1.0)
+        Dimension in the `x,y,z` directions.
+
+    vert : int, default=15
+        Number of vertices along the circumference.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    N = vert
+    phi = np.linspace(0, 2 * np.pi, vert, endpoint=False)
+    theta = np.linspace(-np.pi / 2, np.pi / 2, vert, endpoint=True)
+    phi, theta = np.meshgrid(phi, theta)
+
+    x = np.cos(theta) * np.sin(phi) * dimension[0] * 0.5
+    y = np.cos(theta) * np.cos(phi) * dimension[1] * 0.5
+    z = np.sin(theta) * dimension[2] * 0.5
+
+    x, y, z = (
+        x.flatten()[N - 1 : -N + 1],
+        y.flatten()[N - 1 : -N + 1],
+        z.flatten()[N - 1 : -N + 1],
+    )
+    N2 = len(x) - 1
+
+    i1 = [0] * N
+    j1 = np.array([N, *range(1, N)], dtype=int)
+    k1 = np.array([*range(1, N), N], dtype=int)
+
+    i2 = np.concatenate([k1 + i * N for i in range(N - 3)])
+    j2 = np.concatenate([j1 + i * N for i in range(N - 3)])
+    k2 = np.concatenate([j1 + (i + 1) * N for i in range(N - 3)])
+
+    i3 = np.concatenate([k1 + i * N for i in range(N - 3)])
+    j3 = np.concatenate([j1 + (i + 1) * N for i in range(N - 3)])
+    k3 = np.concatenate([k1 + (i + 1) * N for i in range(N - 3)])
+
+    i4 = [N2] * N
+    j4 = k1 + N2 - N - 1
+    k4 = j1 + N2 - N - 1
+
+    i = np.concatenate([i1, i2, i3, i4])
+    j = np.concatenate([j1, j2, j3, j4])
+    k = np.concatenate([k1, k2, k3, k4])
+
+    trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k}
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_CylinderSegment(
+    backend="generic",
+    dimension=(1.0, 2.0, 1.0, 0.0, 90.0),
+    vert=50,
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a cylinder segment in dictionary form, based on
+    inner and outer diameters, height, and section angles in degrees. The zero position is at
+    `z=0` at the center point of the arcs.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    dimension: array_like, shape (5,), default=`None`
+        Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2)
+        where r1<r2 denote inner and outer radii in units of m, phi1<phi2 denote
+        the cylinder section angles in units of deg and h is the cylinder height
+        in units of m.
+
+    vert : int, default=50
+        Number of vertices along a the complete 360 degrees arc. The number along the phi1-phi2-arc
+        is computed with `max(5, int(vert * abs(phi1 - phi2) / 360))`.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    r1, r2, h, phi1, phi2 = dimension
+    N = max(5, int(vert * abs(phi1 - phi2) / 360))
+    phi = np.linspace(phi1, phi2, N)
+    x = np.cos(np.deg2rad(phi))
+    y = np.sin(np.deg2rad(phi))
+    z = np.zeros(N)
+    c1 = np.array([r1 * x, r1 * y, z + h / 2])
+    c2 = np.array([r2 * x, r2 * y, z + h / 2])
+    c3 = np.array([r1 * x, r1 * y, z - h / 2])
+    c4 = np.array([r2 * x, r2 * y, z - h / 2])
+    x, y, z = np.concatenate([c1, c2, c3, c4], axis=1)
+
+    i1 = np.arange(N - 1)
+    j1 = i1 + N
+    k1 = i1 + 1
+
+    i2 = k1
+    j2 = j1
+    k2 = j1 + 1
+
+    i3 = i1
+    j3 = k1
+    k3 = j1 + N
+
+    i4 = k3 + 1
+    j4 = k3
+    k4 = k1
+
+    i5 = np.array([0, N])
+    j5 = np.array([2 * N, 0])
+    k5 = np.array([3 * N, 3 * N])
+
+    i = [i1, i2, i1 + 2 * N, i2 + 2 * N, i3, i4, i3 + N, i4 + N]
+    j = [j1, j2, k1 + 2 * N, k2 + 2 * N, j3, j4, k3 + N, k4 + N]
+    k = [k1, k2, j1 + 2 * N, j2 + 2 * N, k3, k4, j3 + N, j4 + N]
+
+    if phi2 - phi1 != 360:
+        i.extend([i5, i5 + N - 1])
+        j.extend([k5, k5 + N - 1])
+        k.extend([j5, j5 + N - 1])
+    i, j, k = (np.hstack(m) for m in (i, j, k))
+
+    trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k}
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_Pyramid(
+    backend="generic",
+    base=3,
+    diameter=1,
+    height=1,
+    pivot="middle",
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a pyramid in dictionary form, based on
+    number of vertices of the base, diameter and height. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    base : int, default=30
+        Number of vertices of the cone base.
+
+    diameter : float, default=1
+        Diameter of the cone base.
+
+    height : int, default=1
+        Pyramid height.
+
+    pivot : str, default='middle'
+        The part of the cone that is anchored to the grid and about which it rotates.
+        Can be one of `['tail', 'middle', 'tip']`.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    pivot_conditions = {
+        "tail": height / 2,
+        "tip": -height / 2,
+        "middle": 0,
+    }
+    z_shift = validate_pivot(pivot, pivot_conditions)
+    N = base
+    t = np.linspace(0, 2 * np.pi, N, endpoint=False)
+    c = np.array([np.cos(t), np.sin(t), t * 0 - 1]) * 0.5
+    tp = np.array([[0, 0, 0.5]]).T
+    c = np.concatenate([c, tp], axis=1)
+    c = c.T * np.array([diameter, diameter, height]) + np.array([0, 0, z_shift])
+    x, y, z = c.T
+
+    i = np.arange(N, dtype=int)
+    j = i + 1
+    j[-1] = 0
+    k = np.array([N] * N, dtype=int)
+    trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k}
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_Arrow(
+    backend="generic",
+    base=3,
+    diameter=0.3,
+    height=1,
+    pivot="middle",
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for an arrow in dictionary form, based on
+    number of vertices of the base, diameter and height. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    base : int, default=30
+        Number of vertices of the arrow base.
+
+    diameter : float, default=0.3
+        Diameter of the arrow base.
+
+    height : int, default=1
+        Arrow height.
+
+    pivot : str, default='middle'
+        The part of the arrow that is anchored to the grid and about which it rotates.
+        Can be one of `['tail', 'middle', 'tip']`.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+
+    h, d, z = height, diameter, 0
+    pivot_conditions = {
+        "tail": h / 2,
+        "tip": -h / 2,
+        "middle": 0,
+    }
+    z = validate_pivot(pivot, pivot_conditions)
+    cone = make_Pyramid(
+        backend="plotly-dict",
+        base=base,
+        diameter=d,
+        height=d,
+        position=(0, 0, z + h / 2 - d / 2),
+    )
+    prism = make_Prism(
+        backend="plotly-dict",
+        base=base,
+        diameter=d / 2,
+        height=h - d,
+        position=(0, 0, z + -d / 2),
+    )
+    trace = merge_mesh3d(cone, prism)
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_Tetrahedron(
+    backend="generic",
+    vertices=None,
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a pyramid in dictionary form, based on
+    number of vertices of the base, diameter and height. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`.
+
+    vertices: ndarray, shape (4,3)
+        Vertices (x1,y1,z1), (x2,y2,z2), (x3,y3,z3), (x4,y4,z4), in the relative
+        coordinate system of the tetrahedron.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    # create triangles implying right vertices chirality
+    triangles = np.array([[0, 2, 1], [0, 3, 2], [1, 3, 0], [1, 2, 3]])
+    points = check_chirality(np.array([vertices]))[0]
+    trace = dict(zip("xyzijk", [*points.T, *triangles.T], strict=False))
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
+
+
+def make_TriangularMesh(
+    backend="generic",
+    vertices=None,
+    faces=None,
+    position=None,
+    orientation=None,
+    show=True,
+    scale=1,
+    **kwargs,
+) -> dict:
+    """Provides the 3D-model parameters for a custom triangular mesh in dictionary form, based on
+    number of vertices of the base, diameter and height. The zero position is in the
+    barycenter of the vertices.
+
+    Parameters
+    ----------
+    backend : str
+        Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`.
+
+    vertices: ndarray, shape (4,3)
+        Vertices (x1,y1,z1), (x2,y2,z2), (x3,y3,z3), (x4,y4,z4), in the relative
+        coordinate system of the triangular mesh.
+
+    faces: ndarray, shape (4,3)
+        For each triangle, the indices of the three points that make up the triangle, ordered in an
+        anticlockwise manner. If not specified, a `scipy.spatial.ConvexHull` triangulation is
+        calculated.
+
+    position : array_like, shape (3,), default=(0,0,0)
+        Reference position of the vertices in the global CS. The zero position is
+        in the barycenter of the vertices.
+
+    orientation : scipy Rotation object with length 1 or m, default=`identity`
+        Orientation of the vertices in the global CS.
+
+    show : bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale : float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    **kwargs : dict, optional
+        Additional keyword arguments to be passed to the trace constructor directly.
+        (e.g. `opacity=0.5` for plotly or `alpha=0.5` for matplotlib)
+
+    Returns
+    -------
+    3D-model: dict
+        A dictionary with necessary key/value pairs with the necessary information to construct
+        a 3D-model.
+    """
+    vertices = np.array(vertices)
+    x, y, z = vertices.T
+    if faces is None:
+        hull = ConvexHull(vertices)
+        faces = hull.simplices
+    i, j, k = np.array(faces).T
+    trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k}
+    trace = place_and_orient_model3d(trace, orientation=orientation, position=position)
+    return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs)
diff --git a/src/magpylib/_src/display/traces_core.py b/src/magpylib/_src/display/traces_core.py
new file mode 100644
index 000000000..d64fa4f41
--- /dev/null
+++ b/src/magpylib/_src/display/traces_core.py
@@ -0,0 +1,604 @@
+"""Generic trace drawing functionalities"""
+
+# pylint: disable=C0302
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-statements
+# pylint: disable=too-many-nested-blocks
+# pylint: disable=cyclic-import
+from __future__ import annotations
+
+import warnings
+from itertools import combinations, cycle
+from typing import Any
+
+import numpy as np
+from scipy.spatial import distance
+from scipy.spatial.transform import Rotation as RotScipy
+
+from magpylib._src.display.sensor_mesh import get_sensor_mesh
+from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow
+from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid
+from magpylib._src.display.traces_base import (
+    make_CylinderSegment as make_BaseCylinderSegment,
+)
+from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid
+from magpylib._src.display.traces_base import make_Prism as make_BasePrism
+from magpylib._src.display.traces_base import make_Pyramid as make_BasePyramid
+from magpylib._src.display.traces_base import make_Tetrahedron as make_BaseTetrahedron
+from magpylib._src.display.traces_base import (
+    make_TriangularMesh as make_BaseTriangularMesh,
+)
+from magpylib._src.display.traces_utility import (
+    create_null_dim_trace,
+    draw_arrow_from_vertices,
+    draw_arrow_on_circle,
+    get_legend_label,
+    merge_mesh3d,
+    place_and_orient_model3d,
+    triangles_area,
+)
+
+
+def make_DefaultTrace(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]:
+    """
+    Creates the plotly scatter3d parameters for an object with no specifically supported
+    representation. The object will be represented by a scatter point and text above with object
+    name.
+    """
+    style = obj.style
+    trace = {
+        "type": "scatter3d",
+        "x": [0.0],
+        "y": [0.0],
+        "z": [0.0],
+        "mode": "markers",
+        "marker_size": 10,
+        "marker_color": style.color,
+        "marker_symbol": "diamond",
+    }
+    return {**trace, **kwargs}
+
+
+def make_Polyline(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]:
+    """
+    Creates the plotly scatter3d parameters for a Polyline current in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.vertices is None:
+        trace = create_null_dim_trace(color=style.color)
+        return {**trace, **kwargs}
+
+    traces = []
+    for kind in ("arrow", "line"):
+        kind_style = getattr(style, kind)
+        if kind_style.show:
+            color = style.color if kind_style.color is None else kind_style.color
+            if kind == "arrow":
+                current = 0 if obj.current is None else obj.current
+                x, y, z = draw_arrow_from_vertices(
+                    vertices=obj.vertices,
+                    sign=np.sign(current),
+                    arrow_size=kind_style.size,
+                    arrow_pos=style.arrow.offset,
+                    scaled=kind_style.sizemode == "scaled",
+                    include_line=False,
+                ).T
+            else:
+                x, y, z = obj.vertices.T
+            trace = {
+                "type": "scatter3d",
+                "x": x,
+                "y": y,
+                "z": z,
+                "mode": "lines",
+                "line_width": kind_style.width,
+                "line_dash": kind_style.style,
+                "line_color": color,
+            }
+            traces.append({**trace, **kwargs})
+    return traces
+
+
+def make_Circle(obj, base=72, **kwargs) -> dict[str, Any] | list[dict[str, Any]]:
+    """
+    Creates the plotly scatter3d parameters for a Circle current in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.diameter is None:
+        trace = create_null_dim_trace(color=style.color)
+        return {**trace, **kwargs}
+    traces = []
+    for kind in ("arrow", "line"):
+        kind_style = getattr(style, kind)
+        if kind_style.show:
+            color = style.color if kind_style.color is None else kind_style.color
+
+            if kind == "arrow":
+                angle_pos_deg = 360 * np.round(style.arrow.offset * base) / base
+                current = 0 if obj.current is None else obj.current
+                vertices = draw_arrow_on_circle(
+                    sign=np.sign(current),
+                    diameter=obj.diameter,
+                    arrow_size=style.arrow.size,
+                    scaled=kind_style.sizemode == "scaled",
+                    angle_pos_deg=angle_pos_deg,
+                )
+                x, y, z = vertices.T
+            else:
+                t = np.linspace(0, 2 * np.pi, base)
+                x = np.cos(t) * obj.diameter / 2
+                y = np.sin(t) * obj.diameter / 2
+                z = np.zeros(x.shape)
+            trace = {
+                "type": "scatter3d",
+                "x": x,
+                "y": y,
+                "z": z,
+                "mode": "lines",
+                "line_width": kind_style.width,
+                "line_dash": kind_style.style,
+                "line_color": color,
+            }
+            traces.append({**trace, **kwargs})
+    return traces
+
+
+def make_Dipole(obj, autosize=None, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a dipole in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    moment = np.array([0.0, 0.0, 0.0]) if obj.moment is None else obj.moment
+    moment_mag = np.linalg.norm(moment)
+    size = style.size
+    if autosize is not None and style.sizemode == "scaled":
+        size *= autosize
+    if moment_mag == 0:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        trace = make_BaseArrow(
+            "plotly-dict",
+            base=10,
+            diameter=0.3 * size,
+            height=size,
+            pivot=style.pivot,
+            color=style.color,
+        )
+        nvec = np.array(moment) / moment_mag
+        zaxis = np.array([0, 0, 1])
+        cross = np.cross(nvec, zaxis)
+        n = np.linalg.norm(cross)
+        if n == 0:
+            n = 1
+            cross = np.array([-np.sign(nvec[-1]), 0, 0])
+        dot = np.dot(nvec, zaxis)
+        t = np.arccos(dot)
+        vec = -t * cross / n
+        mag_orient = RotScipy.from_rotvec(vec)
+        trace = place_and_orient_model3d(trace, orientation=mag_orient, **kwargs)
+    return {**trace, **kwargs}
+
+
+def make_Cuboid(obj, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.dimension is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        trace = make_BaseCuboid(
+            "plotly-dict", dimension=obj.dimension, color=style.color
+        )
+    return {**trace, **kwargs}
+
+
+def make_Cylinder(obj, base=50, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.dimension is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        diameter, height = obj.dimension
+        trace = make_BasePrism(
+            "plotly-dict",
+            base=base,
+            diameter=diameter,
+            height=height,
+            color=style.color,
+        )
+    return {**trace, **kwargs}
+
+
+def make_CylinderSegment(obj, vertices=25, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.dimension is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        trace = make_BaseCylinderSegment(
+            "plotly-dict", dimension=obj.dimension, vert=vertices, color=style.color
+        )
+    return {**trace, **kwargs}
+
+
+def make_Sphere(obj, vertices=15, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+
+    if obj.diameter is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        vertices = min(max(vertices, 3), 20)
+        trace = make_BaseEllipsoid(
+            "plotly-dict",
+            vert=vertices,
+            dimension=[obj.diameter] * 3,
+            color=style.color,
+        )
+    return {**trace, **kwargs}
+
+
+def make_Tetrahedron(obj, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Tetrahedron Magnet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    if obj.vertices is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        trace = make_BaseTetrahedron(
+            "plotly-dict", vertices=obj.vertices, color=style.color
+        )
+    return {**trace, **kwargs}
+
+
+def make_triangle_orientations(obj, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a triangle orientation cone or arrow3d in a dictionary
+    based on the provided arguments.
+    """
+    # pylint: disable=protected-access
+    style = obj.style
+    orient = style.orientation
+    size = orient.size
+    symbol = orient.symbol
+    offset = orient.offset
+    color = style.color if orient.color is None else orient.color
+    vertices = obj.mesh if hasattr(obj, "mesh") else [obj.vertices]
+    traces = []
+    for vert in vertices:
+        vec = np.cross(vert[1] - vert[0], vert[2] - vert[1])
+        nvec = vec / np.linalg.norm(vec)
+        # arrow length proportional to square root of triangle
+        length = np.sqrt(triangles_area(np.expand_dims(vert, axis=0))[0]) * 0.2
+        zaxis = np.array([0, 0, 1])
+        cross = np.cross(nvec, zaxis)
+        n = np.linalg.norm(cross)
+        if n == 0:
+            n = 1
+            cross = np.array([-np.sign(nvec[-1]), 0, 0])
+        dot = np.dot(nvec, zaxis)
+        t = np.arccos(dot)
+        vec = -t * cross / n
+        orient = RotScipy.from_rotvec(vec)
+        make_fn = make_BasePyramid if symbol == "cone" else make_BaseArrow
+        vmean = np.mean(vert, axis=0)
+        vmean -= (1 - offset) * length * nvec * size
+        tr = make_fn(
+            "plotly-dict",
+            base=10,
+            diameter=0.5 * size * length,
+            height=size * length,
+            pivot="tail",
+            color=color,
+            position=vmean,
+            orientation=orient,
+            **kwargs,
+        )
+        traces.append(tr)
+    trace = merge_mesh3d(*traces)
+    trace["ismagnet"] = False  # needed to avoid updating mag mesh
+    return trace
+
+
+def get_closest_vertices(faces_subsets, vertices):
+    """Get closest pairs of points between disconnected subsets of faces indices"""
+    # pylint: disable=used-before-assignment
+    nparts = len(faces_subsets)
+    inds_subsets = [np.unique(v) for v in faces_subsets]
+    closest_verts_list = []
+    if nparts > 1:
+        connected = [np.min(inds_subsets[0])]
+        while len(connected) < nparts:
+            prev_min = float("inf")
+            for i in connected:
+                for j in range(nparts):
+                    if j not in connected:
+                        tr1, tr2 = inds_subsets[i], inds_subsets[j]
+                        c1, c2 = vertices[tr1], vertices[tr2]
+                        dist = distance.cdist(c1, c2)
+                        i1, i2 = divmod(dist.argmin(), dist.shape[1])
+                        min_dist = dist[i1, i2]
+                        if min_dist < prev_min:
+                            prev_min = min_dist
+                            closest_verts = [c1[i1], c2[i2]]
+                            connected_ind = j
+            connected.append(connected_ind)
+            closest_verts_list.append(closest_verts)
+    return np.array(closest_verts_list)
+
+
+def make_mesh_lines(obj, mode, **kwargs) -> dict[str, Any]:
+    """Draw mesh lines and vertices"""
+    # pylint: disable=protected-access
+    kwargs.pop("color", None)
+    legendgroup = kwargs.pop("legendgroup", obj)
+    style = obj.style
+    mesh = getattr(style.mesh, mode)
+    marker, line = mesh.marker, mesh.line
+    tr, vert = obj.faces, obj.vertices
+    if mode == "disconnected":
+        subsets = obj.get_faces_subsets()
+        lines = get_closest_vertices(subsets, vert)
+    else:
+        if mode == "selfintersecting":
+            tr = obj.faces[obj.get_selfintersecting_faces()]
+        edges = np.concatenate([tr[:, 0:2], tr[:, 1:3], tr[:, ::2]], axis=0)
+        edges = obj.get_open_edges() if mode == "open" else np.unique(edges, axis=0)
+        lines = vert[edges]
+
+    if lines.size == 0:
+        return {}
+    lines = np.insert(lines, 2, None, axis=1).reshape(-1, 3)
+    x, y, z = lines.T
+    trace = {
+        "type": "scatter3d",
+        "x": x,
+        "y": y,
+        "z": z,
+        "mode": "markers+lines",
+        "marker_color": marker.color,
+        "marker_size": marker.size,
+        "marker_symbol": marker.symbol,
+        "line_color": line.color,
+        "line_width": line.width,
+        "line_dash": line.style,
+        "legendgroup": f"{legendgroup} - {mode}edges",
+        "name_suffix": f" - {mode}-edges",
+        "name": get_legend_label(obj, suffix=False),
+    }
+    return {**trace, **kwargs}
+
+
+def make_Triangle(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]:
+    """
+    Creates the plotly mesh3d parameters for a Triangular facet in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    vert = obj.vertices
+
+    if vert is None:
+        trace = create_null_dim_trace(color=style.color)
+    else:
+        vec = np.cross(vert[1] - vert[0], vert[2] - vert[1])
+        faces = np.array([[0, 1, 2]])
+        # if magnetization is normal to the triangle, add a second triangle slightly above to enable
+        # proper color gradient visualization. Otherwise only the middle color is shown.
+        magnetization = (
+            np.array([0.0, 0.0, 0.0])
+            if obj.magnetization is None
+            else obj.magnetization
+        )
+        if np.all(np.cross(magnetization, vec) == 0):
+            epsilon = 1e-3 * vec
+            vert = np.concatenate([vert - epsilon, vert + epsilon])
+            side_faces = [
+                [0, 1, 3],
+                [1, 2, 4],
+                [2, 0, 5],
+                [1, 4, 3],
+                [2, 5, 4],
+                [0, 3, 5],
+            ]
+            faces = np.concatenate([faces, [[3, 4, 5]], side_faces])
+
+        trace = make_BaseTriangularMesh(
+            "plotly-dict", vertices=vert, faces=faces, color=style.color
+        )
+    traces = [{**trace, **kwargs}]
+    if vert is not None and style.orientation.show:
+        traces.append(make_triangle_orientations(obj, **kwargs))
+    return traces
+
+
+def make_TriangularMesh_single(obj, **kwargs) -> dict[str, Any]:
+    """
+    Creates the plotly mesh3d parameters for a Triangular facet mesh in a dictionary based on the
+    provided arguments.
+    """
+    style = obj.style
+    trace = make_BaseTriangularMesh(
+        "plotly-dict", vertices=obj.vertices, faces=obj.faces, color=style.color
+    )
+    trace["name"] = get_legend_label(obj)
+    # make edges sharper in plotly
+    trace.update(flatshading=True, lighting_facenormalsepsilon=0, lighting_ambient=0.7)
+    return {**trace, **kwargs}
+
+
+def make_TriangularMesh(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]:
+    """
+    Creates the plotly mesh3d parameters for a Triangular facet mesh in a dictionary based on the
+    provided arguments.
+    """
+    # pylint: disable=protected-access
+    style = obj.style
+    is_disconnected = False
+    for mode in ("open", "disconnected", "selfintersecting"):
+        show_mesh = getattr(style.mesh, mode).show
+        if mode == "open" and show_mesh:
+            if obj.status_open is None:
+                warnings.warn(
+                    f"Unchecked open mesh status in {obj!r} detected, before attempting "
+                    "to show potential open edges, which may take a while to compute "
+                    "when the mesh has many faces, now applying operation...",
+                    stacklevel=2,
+                )
+                obj.check_open()
+        elif mode == "disconnected" and show_mesh:
+            if obj.status_disconnected is None:
+                warnings.warn(
+                    f"Unchecked disconnected mesh status in {obj!r} detected, before "
+                    "attempting to show possible disconnected parts, which may take a while "
+                    "to compute when the mesh has many faces, now applying operation...",
+                    stacklevel=2,
+                )
+            is_disconnected = obj.check_disconnected()
+        elif mode == "selfintersecting" and obj._status_selfintersecting is None:
+            warnings.warn(
+                f"Unchecked selfintersecting mesh status in {obj!r} detected, before "
+                "attempting to show possible disconnected parts, which may take a while "
+                "to compute when the mesh has many faces, now applying operation...",
+                stacklevel=2,
+            )
+            obj.check_selfintersecting()
+
+    if is_disconnected:
+        tria_orig = obj._faces
+        obj.style.magnetization.mode = "arrow"
+        traces = []
+        subsets = obj.get_faces_subsets()
+        col_seq = cycle(obj.style.mesh.disconnected.colorsequence)
+        exponent = np.log10(len(subsets)).astype(int) + 1
+        for ind, (tri, dis_color) in enumerate(zip(subsets, col_seq, strict=False)):
+            # temporary mutate faces from subset
+            obj._faces = tri
+            obj.style.magnetization.show = False
+            tr = make_TriangularMesh_single(obj, **{**kwargs, "color": dis_color})
+            # match first group with path scatter trace
+            lg_suff = "" if ind == 0 else f"- part_{ind + 1:02d}"
+            tr["legendgroup"] = f"{kwargs.get('legendgroup', obj)}{lg_suff}"
+            tr["name_suffix"] = f" - part_{ind + 1:0{exponent}d}"
+            traces.append(tr)
+            if style.orientation.show:
+                traces.append(
+                    make_triangle_orientations(
+                        obj,
+                        **{**kwargs, "legendgroup": tr["legendgroup"]},
+                    )
+                )
+        obj._faces = tria_orig
+    else:
+        traces = [make_TriangularMesh_single(obj, **kwargs)]
+        if style.orientation.show:
+            traces.append(
+                make_triangle_orientations(
+                    obj,
+                    **kwargs,
+                )
+            )
+    for mode in ("grid", "open", "disconnected", "selfintersecting"):
+        if getattr(style.mesh, mode).show:
+            trace = make_mesh_lines(obj, mode, **kwargs)
+            if trace:
+                traces.append(trace)
+    return traces
+
+
+def make_Pixels(positions, size=1) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size
+    For now, only "cube" shape is provided.
+    """
+    pixels = [
+        make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3)
+        for p in positions
+    ]
+    return merge_mesh3d(*pixels)
+
+
+def make_Sensor(obj, autosize=None, **kwargs) -> dict[str, Any]:
+    """
+    Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the
+    provided arguments.
+
+    size_pixels: float, default=1
+        A positive number. Adjusts automatic display size of sensor pixels. When set to 0,
+        pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum
+        distance between any pixel of the same sensor, equal to `size_pixel`.
+    """
+    style = obj.style
+    dimension = getattr(obj, "dimension", style.size)
+    pixel = obj.pixel
+    no_pix = pixel is None
+    if not no_pix:
+        pixel = np.unique(np.array(pixel).reshape((-1, 3)), axis=0)
+    one_pix = not no_pix and pixel.shape[0] == 1
+    style_arrows = style.arrows.as_dict(flatten=True, separator="_")
+    sensor = get_sensor_mesh(
+        **style_arrows, center_color=style.color, handedness=obj.handedness
+    )
+    vertices = np.array([sensor[k] for k in "xyz"]).T
+    if style.color is not None:
+        sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = style.color
+    dim = np.array(
+        [dimension] * 3 if isinstance(dimension, float | int) else dimension[:3],
+        dtype=float,
+    )
+    if autosize is not None and style.sizemode == "scaled":
+        dim *= autosize
+    if no_pix:
+        dim_ext = dim
+    else:
+        if one_pix:
+            pixel = np.concatenate([[[0, 0, 0]], pixel])
+        hull_dim = pixel.max(axis=0) - pixel.min(axis=0)
+        dim_ext = max(np.mean(dim), np.min(hull_dim))
+    cube_mask = (abs(vertices) < 1).all(axis=1)
+    vertices[cube_mask] = 0 * vertices[cube_mask]
+    vertices[~cube_mask] = dim_ext * vertices[~cube_mask]
+    vertices /= 2  # sensor_mesh vertices are of length 2
+    x, y, z = vertices.T
+    sensor.update(x=x, y=y, z=z)
+    meshes_to_merge = [sensor]
+    if not no_pix:
+        pixel_color = style.pixel.color
+        pixel_size = style.pixel.size
+        pixel_dim = 1
+        if style.pixel.sizemode == "scaled":
+            combs = np.array(list(combinations(pixel, 2)))
+            vecs = np.diff(combs, axis=1)
+            dists = np.linalg.norm(vecs, axis=2)
+            min_dist = np.min(dists)
+            pixel_dim = dim_ext / 5 if min_dist == 0 else min_dist / 2
+        if pixel_size > 0:
+            pixel_dim *= pixel_size
+            poss = pixel[1:] if one_pix else pixel
+            pixels_mesh = make_Pixels(positions=poss, size=pixel_dim)
+            pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"]))
+            meshes_to_merge.append(pixels_mesh)
+        hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0))
+        hull_dim[hull_dim == 0] = pixel_dim / 2
+        hull_mesh = make_BaseCuboid(
+            "plotly-dict", position=hull_pos, dimension=hull_dim
+        )
+        hull_mesh["facecolor"] = np.repeat(style.color, len(hull_mesh["i"]))
+        meshes_to_merge.append(hull_mesh)
+    trace = merge_mesh3d(*meshes_to_merge)
+    return {**trace, **kwargs}
diff --git a/src/magpylib/_src/display/traces_generic.py b/src/magpylib/_src/display/traces_generic.py
new file mode 100644
index 000000000..f051ce627
--- /dev/null
+++ b/src/magpylib/_src/display/traces_generic.py
@@ -0,0 +1,966 @@
+"""Generic trace drawing functionalities"""
+
+# pylint: disable=C0302
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-statements
+# pylint: disable=too-many-nested-blocks
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numbers
+import warnings
+from collections import Counter
+from itertools import chain, cycle
+
+import numpy as np
+
+import magpylib as magpy
+from magpylib._src.defaults.defaults_classes import default_settings
+from magpylib._src.defaults.defaults_utility import (
+    ALLOWED_LINESTYLES,
+    ALLOWED_SYMBOLS,
+    linearize_dict,
+)
+from magpylib._src.display.traces_utility import (
+    draw_arrowed_line,
+    get_legend_label,
+    get_objects_props_by_row_col,
+    get_rot_pos_from_path,
+    get_scene_ranges,
+    getColorscale,
+    getIntensity,
+    group_traces,
+    place_and_orient_model3d,
+    rescale_traces,
+    slice_mesh_from_colorscale,
+)
+from magpylib._src.style import DefaultMarkers
+from magpylib._src.utility import (
+    format_obj_input,
+    get_unit_factor,
+    style_temp_edit,
+    unit_prefix,
+)
+
+
+class MagpyMarkers:
+    """A class that stores markers 3D-coordinates."""
+
+    def __init__(self, *markers):
+        self._style = DefaultMarkers()
+        self.markers = np.array(markers)
+
+    @property
+    def style(self):
+        """Style property"""
+        return self._style
+
+    def get_trace(self, **kwargs):
+        """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the
+        provided arguments."""
+        style = self.style
+        x, y, z = self.markers.T
+        marker_kwargs = {
+            f"marker_{k}": v
+            for k, v in style.marker.as_dict(flatten=True, separator="_").items()
+        }
+        marker_kwargs["marker_color"] = (
+            style.marker.color if style.marker.color is not None else style.color
+        )
+        trace = {
+            "type": "scatter3d",
+            "x": x,
+            "y": y,
+            "z": z,
+            "mode": "markers",
+            "showlegend": style.legend.show,  # pylint: disable=no-member
+            **marker_kwargs,
+            **kwargs,
+        }
+        name = "Marker" if len(x) == 1 else "Markers"
+        suff = "" if len(x) == 1 else f" ({len(x)} points)"
+        trace["name"] = f"{name}{suff}"
+        return trace
+
+
+def update_magnet_mesh(
+    mesh_dict, mag_style=None, magnetization=None, color_slicing=False
+):
+    """
+    Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The
+    object gets colorized, positioned and oriented based on provided arguments.
+    Slicing allows for matplotlib to show colorgradients approximations by slicing the mesh into
+    the colorscales colors, remesh it and merge with assigning facecolor for each part.
+    """
+    mag_color = mag_style.color
+    if magnetization is None:
+        magnetization = np.array([0.0, 0.0, 0.0], dtype=float)
+    if mag_style.show:
+        vertices = np.array([mesh_dict[k] for k in "xyz"]).T
+        color_middle = mag_color.middle
+        if mag_color.mode == "tricycle":
+            color_middle = mesh_dict["color"]
+        elif mag_color.mode == "bicolor":
+            color_middle = False
+        ct = mag_color.transition
+        cs = getColorscale(
+            color_transition=0 if color_slicing else ct,
+            color_north=mag_color.north,
+            color_middle=color_middle,
+            color_south=mag_color.south,
+        )
+        if color_slicing:
+            if np.all(magnetization == 0):
+                mesh_dict["color"] = mag_color.middle
+            else:
+                tr = slice_mesh_from_colorscale(mesh_dict, magnetization, cs)
+                mesh_dict.update(tr)
+        else:
+            mesh_dict["colorscale"] = cs
+            mesh_dict["intensity"] = getIntensity(
+                vertices=vertices,
+                axis=magnetization,
+            )
+        mesh_dict["showscale"] = False
+        mesh_dict.pop("color_slicing", None)
+    return mesh_dict
+
+
+def make_mag_arrows(obj):
+    """draw direction of magnetization of faced magnets
+
+    Parameters
+    ----------
+    - obj: object with magnetization vector to be drawn
+    - colors: colors of faced_objects
+    - show_path(bool or int): draw on every position where object is displayed
+    """
+    # pylint: disable=protected-access
+
+    # vector length, color and magnetization
+    style = obj.style
+    arrow = style.magnetization.arrow
+    length = 1
+    color = style.color if arrow.color is None else arrow.color
+    if arrow.sizemode == "scaled":
+        if hasattr(obj, "diameter"):
+            length = obj.diameter  # Sphere
+        elif isinstance(obj, magpy.misc.Triangle):
+            length = np.amax(obj.vertices) - np.amin(obj.vertices)
+        elif hasattr(obj, "mesh"):
+            length = np.amax(np.ptp(obj.mesh.reshape(-1, 3), axis=0))
+        elif hasattr(obj, "vertices"):
+            length = np.amax(np.ptp(obj.vertices, axis=0))
+        else:  # Cuboid, Cylinder, CylinderSegment
+            length = np.amax(obj.dimension[:3])
+        length *= 1.5
+    length *= arrow.size
+    mag = obj.magnetization
+    # collect all draw positions and directions
+    pos = getattr(obj, "_barycenter", obj._position)[0] - obj._position[0]
+    # we need initial relative barycenter, arrow gets orientated later
+    pos = obj._orientation[0].inv().apply(pos)
+    direc = mag / (np.linalg.norm(mag) + 1e-6) * length
+    x, y, z = draw_arrowed_line(
+        direc, pos, sign=1, arrow_pos=arrow.offset, pivot="tail"
+    ).T
+    return {
+        "type": "scatter3d",
+        "mode": "lines",
+        "line_width": arrow.width,
+        "line_dash": arrow.style,
+        "line_color": color,
+        "x": x,
+        "y": y,
+        "z": z,
+        "showlegend": False,
+    }
+
+
+def make_path(input_obj, label=None):
+    """draw obj path based on path style properties"""
+    style = input_obj.style
+    x, y, z = np.array(input_obj.position).T
+    txt_kwargs = (
+        {"mode": "markers+text+lines", "text": list(range(len(x)))}
+        if style.path.numbering
+        else {"mode": "markers+lines"}
+    )
+    marker = style.path.marker.as_dict()
+    marker["symbol"] = marker["symbol"]
+    marker["color"] = style.color if marker["color"] is None else marker["color"]
+    line = style.path.line.as_dict()
+    line["dash"] = line["style"]
+    line["color"] = style.color if line["color"] is None else line["color"]
+    line = {k: v for k, v in line.items() if k != "style"}
+    return {
+        "type": "scatter3d",
+        "x": x,
+        "y": y,
+        "z": z,
+        "name": label,
+        **{f"marker_{k}": v for k, v in marker.items()},
+        **{f"line_{k}": v for k, v in line.items()},
+        **txt_kwargs,
+        "opacity": style.opacity,
+    }
+
+
+def get_trace2D_dict(
+    BH,
+    *,
+    field_str,
+    coords_str,
+    obj_lst_str,
+    focus_inds,
+    frames_indices,
+    mode,
+    label_suff,
+    units_polarization,
+    units_magnetization,
+    **kwargs,
+):
+    """return a 2d trace based on field and parameters"""
+    coords_inds = ["xyz".index(k) for k in coords_str]
+    y = BH.T[list(coords_inds)]
+    y = y[0] if len(coords_inds) == 1 else np.linalg.norm(y, axis=0)
+    marker_size = np.array([3] * len(frames_indices))
+    marker_size[focus_inds] = 15
+    title = f"{field_str}{''.join(coords_str)}"
+    unit = (
+        units_polarization
+        if field_str in "BJ"
+        else units_magnetization
+        if field_str in "HM"
+        else ""
+    )
+    trace = {
+        "mode": "lines+markers",
+        "legendgrouptitle_text": f"{title}"
+        + (f" ({label_suff})" if label_suff else ""),
+        "text": mode,
+        "hovertemplate": (
+            "<b>Path index</b>: %{x}    "
+            f"<b>{title}</b>: %{{y:.3s}}{unit}<br>"
+            f"<b>{'sources'}</b>:<br>{obj_lst_str['sources']}<br>"
+            f"<b>{'sensors'}</b>:<br>{obj_lst_str['sensors']}"
+            # "<extra></extra>",
+        ),
+        "x": frames_indices,
+        "y": y[frames_indices],
+        "marker_size": marker_size,
+        "showlegend": True,
+        "legendgroup": f"{title}{label_suff}",
+    }
+    trace.update(kwargs)
+    return trace
+
+
+def get_traces_2D(
+    *objects,
+    output=("Bx", "By", "Bz"),
+    row=None,
+    col=None,
+    sumup=True,
+    pixel_agg=None,
+    in_out="auto",
+    styles=None,
+    units_polarization="T",
+    units_magnetization="A/m",
+    units_length="m",  # noqa: ARG001
+    zoom=0,  # noqa: ARG001
+):
+    """draws and animates sensor values over a path in a subplot"""
+    # pylint: disable=import-outside-toplevel
+    from magpylib._src.fields.field_wrap_BH import getBH_level2
+
+    sources = format_obj_input(objects, allow="sources+collections")
+    sources = [
+        s
+        for s in sources
+        if not (isinstance(s, magpy.Collection) and not s.sources_all)
+    ]
+    sensors = format_obj_input(objects, allow="sensors+collections")
+    sensors = [
+        sub_s
+        for s in sensors
+        for sub_s in (s.sensors_all if isinstance(s, magpy.Collection) else [s])
+    ]
+
+    if not isinstance(output, list | tuple):
+        output = [output]
+    output_params = {}
+    field_str_list = []
+    for out, linestyle in zip(output, cycle(ALLOWED_LINESTYLES[:6])):
+        field_str, *coords_str = out
+        if not coords_str:
+            coords_str = list("xyz")
+        if field_str not in "BHMJ" and set(coords_str).difference(set("xyz")):
+            msg = (
+                "The `output` parameter must start with 'B', 'H', 'M', 'J' "
+                "and be followed by a combination of 'x', 'y', 'z' (e.g. 'Bxy' or ('Bxy', 'Bz') )"
+                f"\nreceived {out!r} instead"
+            )
+            raise ValueError(msg)
+        field_str_list.append(field_str)
+        output_params[out] = {
+            "field_str": field_str,
+            "coords_str": coords_str,
+            "line_dash": linestyle,
+        }
+    field_str_list = list(dict.fromkeys(field_str_list))
+    BH_array = {}
+    for field_str in field_str_list:
+        BH_array[field_str] = getBH_level2(
+            sources,
+            sensors,
+            sumup=sumup,
+            squeeze=False,
+            field=field_str,
+            pixel_agg=pixel_agg,
+            output="ndarray",
+            in_out=in_out,
+        )
+        # swap axes to have sensors first, path second
+        BH_array[field_str] = BH_array[field_str].swapaxes(1, 2)
+    frames_indices = np.arange(0, BH_array[field_str_list[0]].shape[2])
+
+    def get_focus_inds(*objs):
+        focus_inds = []
+        for obj in objs:
+            style = styles.get(obj, obj.style)
+            frames = style.path.frames
+            inds = [] if frames is None else frames
+            if isinstance(inds, numbers.Number):
+                # pylint: disable=invalid-unary-operand-type
+                inds = frames_indices[::-frames]
+            focus_inds.extend(inds)
+        focus_inds = list(dict.fromkeys(focus_inds))
+        return focus_inds if focus_inds else [-1]
+
+    def get_obj_list_str(objs):
+        if len(objs) < 8:
+            obj_lst_str = "<br>".join(f" - {s}" for s in objs)
+        else:
+            counts = Counter(s.__class__.__name__ for s in objs)
+            obj_lst_str = "<br>".join(f" {v}x {k}" for k, v in counts.items())
+        return obj_lst_str
+
+    def get_label_and_color(obj):
+        style = styles.get(obj, None)
+        style = obj.style if style is None else style
+        label = get_legend_label(obj, style=style)
+        color = getattr(style, "color", None)
+        return label, color
+
+    obj_lst_str = {
+        "sources": get_obj_list_str(sources),
+        "sensors": get_obj_list_str(sensors),
+    }
+    mode = "sources" if sumup else "sensors"
+
+    traces = []
+    for src_ind, src in enumerate(sources):
+        if src_ind == 1 and sumup:
+            break
+        label_src, color_src = get_label_and_color(src)
+        symbols = cycle(ALLOWED_SYMBOLS[:6])
+        for sens_ind, sens in enumerate(sensors):
+            focus_inds = get_focus_inds(src, sens)
+            label_sens, color_sens = get_label_and_color(sens)
+            label_suff = label_sens
+            label = label_src
+            line_color = color_src
+            marker_color = color_sens if len(sensors) > 1 else None
+            if sumup:
+                line_color = color_sens
+                label = label_sens
+                label_suff = (
+                    f"{label_src}" if len(sources) == 1 else f"{len(sources)} sources"
+                )
+            num_of_pix = (
+                len(sens.pixel.reshape(-1, 3))
+                if (not isinstance(sens, magpy.Collection))
+                and sens.pixel is not None
+                and sens.pixel.ndim != 1
+                else 1
+            )
+            pix_suff = ""
+            num_of_pix_to_show = 1 if pixel_agg else num_of_pix
+            for pix_ind in range(num_of_pix_to_show):
+                marker_symbol = next(symbols)
+                if num_of_pix > 1:
+                    if pixel_agg:
+                        pix_suff = f" - {num_of_pix} pixels {pixel_agg}"
+                    else:
+                        pix_suff = f" - pixel {pix_ind}"
+                for param in output_params.values():
+                    BH = BH_array[param["field_str"]][src_ind, sens_ind, :, pix_ind]
+                    traces.append(
+                        get_trace2D_dict(
+                            BH,
+                            **param,
+                            obj_lst_str=obj_lst_str,
+                            focus_inds=focus_inds,
+                            frames_indices=frames_indices,
+                            mode=mode,
+                            label_suff=label_suff,
+                            name=f"{label}{pix_suff}",
+                            line_color=line_color,
+                            marker_color=marker_color,
+                            marker_line_color=marker_color,
+                            marker_symbol=marker_symbol,
+                            type="scatter",
+                            row=row,
+                            col=col,
+                            units_polarization=units_polarization,
+                            units_magnetization=units_magnetization,
+                        )
+                    )
+    return traces
+
+
+def process_extra_trace(model):
+    "process extra trace attached to some magpylib object"
+    extr = model["model3d"]
+    model_kwargs = {**(extr.kwargs() if callable(extr.kwargs) else extr.kwargs)}
+    model_args = extr.args() if callable(extr.args) else extr.args
+    trace3d = {
+        "constructor": extr.constructor,
+        "kwargs": model_kwargs,
+        "args": model_args,
+        "coordsargs": extr.coordsargs,
+        "kwargs_extra": model["kwargs_extra"],
+    }
+    kwargs, args, coordsargs = place_and_orient_model3d(
+        model_kwargs=model_kwargs,
+        model_args=model_args,
+        orientation=model["orientation"],
+        position=model["position"],
+        coordsargs=extr.coordsargs,
+        scale=extr.scale,
+        return_model_args=True,
+        return_coordsargs=True,
+    )
+    trace3d["coordsargs"] = coordsargs
+    trace3d["kwargs"].update(kwargs)
+    trace3d["args"] = args
+    return trace3d
+
+
+def get_generic_traces3D(
+    input_obj,
+    autosize=None,
+    legendgroup=None,
+    legendtext=None,
+    showlegend=None,
+    supports_colorgradient=True,
+    extra_backend=False,
+    row=1,
+    col=1,
+    **kwargs,
+) -> list:
+    """
+    This is a helper function providing the plotly traces for any object of the magpylib library. If
+    the object is not supported, the trace representation will fall back to a single scatter point
+    with the object name marked above it.
+
+    - If the object has a path (multiple positions), the function will return both the object trace
+    and the corresponding path trace. The legend entry of the path trace will be hidden but both
+    traces will share the same `legendgroup` so that a legend entry click will hide/show both traces
+    at once. From the user's perspective, the traces will be merged.
+
+    - The argument caught by the kwargs dictionary must all be arguments supported both by
+    `scatter3d` and `mesh3d` plotly objects, otherwise an error will be raised.
+    """
+
+    # pylint: disable=too-many-branches
+    # pylint: disable=too-many-statements
+    # pylint: disable=too-many-nested-blocks
+    # pylint: disable=protected-access
+    # pylint: disable=import-outside-toplevel
+    style = input_obj.style
+    is_mag_arrows = False
+    is_mag = hasattr(input_obj, "magnetization") and hasattr(style, "magnetization")
+    if is_mag and style.magnetization.show:
+        magstyl = style.magnetization
+        if magstyl.mode == "auto":
+            magstyl.mode = "color" if supports_colorgradient else "arrow"
+        is_mag_arrows = "arrow" in magstyl.mode
+        magstyl.show = "color" in magstyl.mode
+
+    make_func = getattr(input_obj, "get_trace", None)
+    make_func_kwargs = {"legendgroup": legendgroup, **kwargs}
+    if getattr(input_obj, "_autosize", False):
+        make_func_kwargs["autosize"] = autosize
+
+    has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation")
+    path_traces_extra_non_generic_backend = []
+    if not has_path and make_func is not None:
+        tr = make_func(**make_func_kwargs)
+        tr["row"] = row
+        tr["col"] = col
+        out = {"generic": [tr]}
+        if extra_backend:
+            out.update({extra_backend: path_traces_extra_non_generic_backend})
+        return out
+
+    orientations, positions, pos_orient_inds = get_rot_pos_from_path(
+        input_obj, style.path.frames
+    )
+    traces_generic = []
+    if pos_orient_inds.size != 0:
+        if style.model3d.showdefault and make_func is not None:
+            p_trs = make_func(**make_func_kwargs)
+            p_trs = [p_trs] if isinstance(p_trs, dict) else p_trs
+            for p_tr_item in p_trs:
+                p_tr = p_tr_item.copy()
+                is_mag = p_tr.pop("ismagnet", is_mag)
+                if is_mag and p_tr.get("type", "") == "mesh3d":
+                    p_tr = update_magnet_mesh(
+                        p_tr,
+                        mag_style=style.magnetization,
+                        magnetization=input_obj.magnetization,
+                        color_slicing=not supports_colorgradient,
+                    )
+
+                traces_generic.append(p_tr)
+
+        extra_model3d_traces = (
+            style.model3d.data if style.model3d.data is not None else []
+        )
+        for extr in extra_model3d_traces:
+            if not extr.show:
+                continue
+            extr.update(extr.updatefunc())  # update before checking backend
+            if extr.backend == "generic":
+                extr.update(extr.updatefunc())
+                tr_non_generic = {"opacity": style.opacity}
+                ttype = extr.constructor.lower()
+                obj_extr_trace = extr.kwargs() if callable(extr.kwargs) else extr.kwargs
+                obj_extr_trace = {"type": ttype, **obj_extr_trace}
+                if ttype == "scatter3d":
+                    for k in ("marker", "line"):
+                        tr_non_generic[f"{k}_color"] = tr_non_generic.get(
+                            f"{k}_color", style.color
+                        )
+                elif ttype == "mesh3d":
+                    tr_non_generic["showscale"] = tr_non_generic.get("showscale", False)
+                    tr_non_generic["color"] = tr_non_generic.get("color", style.color)
+                else:  # pragma: no cover
+                    msg = f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are"
+                    raise ValueError(msg)
+                tr_non_generic.update(linearize_dict(obj_extr_trace, separator="_"))
+                traces_generic.append(tr_non_generic)
+
+    if is_mag_arrows:
+        mag = input_obj.magnetization
+        mag = np.array([0.0, 0.0, 0.0]) if mag is None else mag
+        if not np.all(mag == 0):
+            mag_arrow_tr = make_mag_arrows(input_obj)
+            traces_generic.append(mag_arrow_tr)
+
+    legend_label = get_legend_label(input_obj)
+
+    path_traces_generic = []
+    for tr in traces_generic:
+        temp_rot_traces = []
+        name_suff = tr.pop("name_suffix", None)
+        name = tr.get("name", "") if legendtext is None else legendtext
+        for orient, pos in zip(orientations, positions, strict=False):
+            tr1 = place_and_orient_model3d(tr, orientation=orient, position=pos)
+            if name_suff is not None:
+                tr1["name"] = f"{name}{name_suff}"
+            temp_rot_traces.append(tr1)
+        path_traces_generic.extend(group_traces(*temp_rot_traces))
+
+    if np.array(input_obj.position).ndim > 1 and style.path.show:
+        scatter_path = make_path(input_obj, legend_label)
+        path_traces_generic.append(scatter_path)
+
+    path_traces_generic = group_traces(*path_traces_generic)
+
+    for tr in path_traces_generic:
+        tr.update(place_and_orient_model3d(tr))
+        tr.update(row=row, col=col)
+        if tr.get("opacity", None) is None:
+            tr["opacity"] = style.opacity
+        if tr.get("legendgroup", None) is None:
+            # allow invalid trimesh traces to have their own legendgroup
+            tr["legendgroup"] = legendgroup
+        if legendtext is not None:
+            tr["name"] = legendtext
+        elif "name" not in tr:
+            tr["name"] = legend_label
+        if tr.get("facecolor", None) is not None:
+            # this allows merging of 3d meshes, ignoring different colors
+            tr["color"] = None
+        tr_showleg = tr.get("showlegend", None)
+        # tr_showleg = True if tr_showleg is None else tr_showleg
+        tr["showlegend"] = (
+            showlegend
+            if showlegend is not None
+            else tr_showleg
+            if style.legend.show
+            else False
+        )
+    out = {"generic": path_traces_generic}
+
+    if extra_backend:
+        for extr in extra_model3d_traces:
+            if not extr.show:
+                continue
+            extr.update(extr.updatefunc())  # update before checking backend
+            if extr.backend == extra_backend:
+                for orient, pos in zip(orientations, positions, strict=False):
+                    tr_non_generic = {
+                        "model3d": extr,
+                        "position": pos,
+                        "orientation": orient,
+                        "kwargs_extra": {
+                            "opacity": style.opacity,
+                            "color": style.color,
+                            "legendgroup": legendgroup,
+                            "showlegend": (
+                                showlegend
+                                if showlegend is not None
+                                else None
+                                if style.legend.show
+                                else False
+                            ),
+                            "name": legendtext if legendtext else legend_label,
+                            "row": row,
+                            "col": col,
+                        },
+                    }
+                    tr_non_generic = process_extra_trace(tr_non_generic)
+                    path_traces_extra_non_generic_backend.append(tr_non_generic)
+        out.update({extra_backend: path_traces_extra_non_generic_backend})
+    return out
+
+
+def clean_legendgroups(frames, clean_2d=False):
+    """removes legend duplicates for a plotly figure"""
+    for fr in frames:
+        legendgroups = {}
+        for tr_item in chain(fr["data"], fr["extra_backend_traces"]):
+            tr = tr_item
+            if "z" in tr or clean_2d or "kwargs_extra" in tr:
+                tr = tr.get("kwargs_extra", tr)
+                lg = tr.get("legendgroup", None)
+                if lg is not None:
+                    tr_showlegend = tr.get("showlegend", None)
+                    tr["showlegend"] = True if tr_showlegend is None else tr_showlegend
+                    if lg not in legendgroups:
+                        legendgroups[lg] = {"traces": [], "backup": None}
+                    else:  # tr.legendgrouptitle.text is None:
+                        tr["showlegend"] = False
+                    legendgroups[lg]["traces"].append(tr)
+                    if tr_showlegend is None:
+                        legendgroups[lg]["backup"] = tr
+    # legends with showlegend
+    for lg in legendgroups.values():  # pragma: no cover
+        if lg["backup"] is not None and all(
+            tr["showlegend"] is False for tr in lg["traces"]
+        ):
+            lg["backup"]["showlegend"] = True
+
+
+def process_animation_kwargs(obj_list, animation=False, **kwargs):
+    """Update animation kwargs"""
+    flat_obj_list = format_obj_input(obj_list)
+    # set animation and animation_time
+    if isinstance(animation, numbers.Number) and not isinstance(animation, bool):
+        kwargs["animation_time"] = animation
+        animation = True
+    if (
+        not any(
+            getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list
+        )
+        and animation is not False
+    ):  # check if some path exist for any object
+        animation = False
+        warnings.warn(
+            "No path to be animated detected, displaying standard plot", stacklevel=2
+        )
+
+    # pylint: disable=no-member
+    anim_def = default_settings.display.animation.copy()
+    anim_def.update({k[10:]: v for k, v in kwargs.items()}, _match_properties=False)
+    animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()}
+    kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")}
+    return kwargs, animation, animation_kwargs
+
+
+def extract_animation_properties(
+    objs,
+    *,
+    animation_maxfps,
+    animation_time,
+    animation_fps,
+    animation_maxframes,
+    animation_slider,  # noqa: ARG001
+    animation_output,  # noqa: ARG001
+):
+    """Extract animation properties"""
+    # pylint: disable=import-outside-toplevel
+    from magpylib._src.obj_classes.class_Collection import Collection
+
+    path_lengths = []
+    for obj in objs:
+        subobjs = [obj]
+        if isinstance(obj, Collection):
+            subobjs.extend(obj.children)
+        for subobj in subobjs:
+            path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0]
+            path_lengths.append(path_len)
+
+    max_pl = max(path_lengths)
+    if animation_fps > animation_maxfps:
+        warnings.warn(
+            f"The set `animation_fps` at {animation_fps} is greater than the max allowed of"
+            f" {animation_maxfps}. `animation_fps` will be set to"
+            f" {animation_maxfps}. "
+            f"You can modify the default value by setting it in "
+            "`magpylib.defaults.display.animation.maxfps`",
+            stacklevel=2,
+        )
+        animation_fps = animation_maxfps
+
+    maxpos = min(animation_time * animation_fps, animation_maxframes)
+
+    if max_pl <= maxpos:
+        path_indices = np.arange(max_pl)
+    else:
+        round_step = max_pl / (maxpos - 1)
+        ar = np.linspace(0, max_pl, max_pl, endpoint=False)
+        path_indices = np.unique(np.floor(ar / round_step) * round_step).astype(
+            int
+        )  # downsampled indices
+        path_indices[-1] = (
+            max_pl - 1
+        )  # make sure the last frame is the last path position
+
+    # calculate exponent of last frame index to avoid digit shift in
+    # frame number display during animation
+    exp = (
+        np.log10(path_indices.max()).astype(int) + 1
+        if path_indices.ndim != 0 and path_indices.max() > 0
+        else 1
+    )
+
+    frame_duration = int(animation_time * 1000 / path_indices.shape[0])
+    new_fps = int(1000 / frame_duration)
+    if max_pl > animation_maxframes:
+        warnings.warn(
+            f"The number of frames ({max_pl}) is greater than the max allowed "
+            f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. "
+            f"You can modify the default value by setting it in "
+            "`magpylib.defaults.display.animation.maxframes`",
+            stacklevel=2,
+        )
+
+    return path_indices, exp, frame_duration
+
+
+def get_traces_3D(flat_objs_props, extra_backend=False, autosize=None, **kwargs):
+    """Return traces, traces to resize and extra_backend_traces"""
+    extra_backend_traces = []
+    traces_dict = {}
+    for obj, params_item in flat_objs_props.items():
+        params = {**params_item, **kwargs}
+        if autosize is None and getattr(obj, "_autosize", False):
+            # temporary coordinates to be able to calculate ranges
+            # pylint: disable=protected-access
+            x, y, z = obj._position.T
+            rc_dict = {k: v for k, v in params.items() if k in ("row", "col")}
+            traces_dict[obj] = [{"x": x, "y": y, "z": z, "_autosize": True, **rc_dict}]
+        else:
+            traces_dict[obj] = []
+            with style_temp_edit(obj, style_temp=params.pop("style", None), copy=True):
+                out_traces = get_generic_traces3D(
+                    obj,
+                    extra_backend=extra_backend,
+                    autosize=autosize,
+                    **params,
+                )
+                if extra_backend:
+                    extra_backend_traces.extend(out_traces.get(extra_backend, []))
+                traces_dict[obj].extend(out_traces["generic"])
+    return traces_dict, extra_backend_traces
+
+
+def draw_frame(objs, *, colorsequence, rc_params, style_kwargs, **kwargs) -> tuple:
+    """
+    Creates traces from input `objs` and provided parameters, updates the size of objects like
+    Sensors and Dipoles in `kwargs` depending on the canvas size.
+
+    Returns
+    -------
+    traces_dicts, kwargs: dict, dict
+        returns the traces in a obj/traces_list dictionary and updated kwargs
+    """
+    if colorsequence is None:
+        # pylint: disable=no-member
+        colorsequence = default_settings.display.colorsequence
+    # dipoles and sensors use autosize, the trace building has to be put at the back of the queue.
+    # autosize is calculated from the other traces overall scene range
+    objs_rc = get_objects_props_by_row_col(
+        *objs,
+        colorsequence=colorsequence,
+        style_kwargs=style_kwargs,
+    )
+    traces_dict = {}
+    extra_backend_traces = []
+    rc_params = {} if rc_params is None else rc_params
+    for rc, props in objs_rc.items():
+        if props["rc_params"]["output"] == "model3d":
+            rc_params[rc] = rc_params.get(rc, {})
+            rc_params[rc]["units_length"] = props["rc_params"]["units_length"]
+            rc_keys = ("row", "col")
+            rc_kwargs = {k: v for k, v in props["rc_params"].items() if k in rc_keys}
+            traces_d1, traces_ex1 = get_traces_3D(
+                props["objects"], **rc_kwargs, **kwargs
+            )
+            rc_params[rc]["autosize"] = rc_params.get(rc, {}).get("autosize", None)
+            if rc_params[rc]["autosize"] is None:
+                zoom = rc_params[rc]["zoom"] = props["rc_params"]["zoom"]
+                traces = [t for tr in traces_d1.values() for t in tr]
+                ranges_rc = get_scene_ranges(*traces, *traces_ex1, zoom=zoom)
+                # pylint: disable=no-member
+                factor = default_settings.display.autosizefactor
+                rc_params[rc]["autosize"] = np.mean(np.diff(ranges_rc[rc])) / factor
+            to_resize_keys = {
+                k for k, v in traces_d1.items() if v and "_autosize" in v[0]
+            }
+            flat_objs_props = {
+                k: v for k, v in props["objects"].items() if k in to_resize_keys
+            }
+            traces_d2, traces_ex2 = get_traces_3D(
+                flat_objs_props,
+                autosize=rc_params[rc]["autosize"],
+                **rc_kwargs,
+                **kwargs,
+            )
+            traces_dict.update(
+                {(k, *rc): v for k, v in {**traces_d1, **traces_d2}.items()}
+            )
+            extra_backend_traces.extend([*traces_ex1, *traces_ex2])
+    traces = group_traces(*[t for tr in traces_dict.values() for t in tr])
+
+    styles = {
+        obj: params.get("style", None)
+        for o_rc in objs_rc.values()
+        for obj, params in o_rc["objects"].items()
+    }
+    for props in objs_rc.values():
+        if props["rc_params"]["output"] != "model3d":
+            traces2d = get_traces_2D(
+                *props["objects"],
+                **props["rc_params"],
+                styles=styles,
+            )
+            traces.extend(traces2d)
+    return traces, extra_backend_traces, rc_params
+
+
+def get_frames(
+    objs,
+    colorsequence=None,
+    title=None,
+    animation=False,
+    supports_colorgradient=True,
+    backend="generic",
+    style_kwargs=None,
+    **kwargs,
+):
+    """This is a helper function which generates frames with generic traces to be provided to
+    the chosen backend. According to a certain zoom level, all three space direction will be equal
+    and match the maximum of the ranges needed to display all objects, including their paths.
+    """
+    # infer title if necessary
+    if objs:
+        style = objs[0]["objects"][0].style
+        label = getattr(style, "label", None)
+        title = label if len(objs[0]["objects"]) == 1 else None
+    else:
+        title = "No objects to be displayed"
+
+    # make sure the number of frames does not exceed the max frames and max frame rate
+    # downsample if necessary
+    obj_list_semi_flat = format_obj_input(
+        [o["objects"] for o in objs], allow="sources+sensors+collections"
+    )
+    kwargs, animation, animation_kwargs = process_animation_kwargs(
+        obj_list_semi_flat, animation=animation, **kwargs
+    )
+    path_indices = [-1]
+    if animation:
+        path_indices, exp, frame_duration = extract_animation_properties(
+            obj_list_semi_flat, **animation_kwargs
+        )
+    # create frame for each path index or downsampled path index
+    frames = []
+
+    title_str = title
+    rc_params = {}
+    for i, ind in enumerate(path_indices):
+        extra_backend_traces = []
+        if animation:
+            style_kwargs["style_path_frames"] = [ind]
+            title = "Animation 3D - " if title is None else title
+            title_str = f"""{title}path index: {ind + 1:0{exp}d}"""
+        traces, extra_backend_traces, rc_params_temp = draw_frame(
+            objs,
+            colorsequence=colorsequence,
+            rc_params=rc_params,
+            supports_colorgradient=supports_colorgradient,
+            extra_backend=backend,
+            style_kwargs=style_kwargs,
+            **kwargs,
+        )
+        if i == 0:  # get the dipoles and sensors autosize from first frame
+            rc_params = rc_params_temp
+        frames.append(
+            {
+                "data": traces,
+                "name": str(ind + 1),
+                "layout": {"title": title_str},
+                "extra_backend_traces": extra_backend_traces,
+            }
+        )
+    clean_legendgroups(frames)
+    traces = [t for frame in frames for t in frame["data"]]
+    zoom = {rc: v["zoom"] for rc, v in rc_params.items()}
+    ranges_rc = get_scene_ranges(*traces, *extra_backend_traces, zoom=zoom)
+    labels_rc = {(1, 1): dict.fromkeys("xyz", "")}
+    scale_factors_rc = {}
+    for rc, params in rc_params.items():
+        units_length = params["units_length"]
+        if units_length == "auto":
+            rmax = np.amax(np.abs(ranges_rc[rc]))
+            units_length = f"{unit_prefix(rmax, as_tuple=True)[2]}m"
+        unit_str = "" if not (units_length) else f" ({units_length})"
+        labels_rc[rc] = {k: f"{k}{unit_str}" for k in "xyz"}
+        scale_factors_rc[rc] = get_unit_factor(units_length, target_unit="m")
+        ranges_rc[rc] *= scale_factors_rc[rc]
+
+    for frame in frames:
+        for key in ("data", "extra_backend_traces"):
+            frame[key] = rescale_traces(frame[key], factors=scale_factors_rc)
+
+    out = {
+        "frames": frames,
+        "ranges": ranges_rc,
+        "labels": labels_rc,
+        "input_kwargs": {**kwargs, **animation_kwargs},
+    }
+    if animation:
+        out.update(
+            {
+                "frame_duration": frame_duration,
+                "path_indices": path_indices,
+            }
+        )
+    return out
diff --git a/src/magpylib/_src/display/traces_utility.py b/src/magpylib/_src/display/traces_utility.py
new file mode 100644
index 000000000..c5c7fd7a5
--- /dev/null
+++ b/src/magpylib/_src/display/traces_utility.py
@@ -0,0 +1,812 @@
+"""Display function codes"""
+
+# pylint: disable=too-many-branches
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+from collections import defaultdict
+from functools import lru_cache
+from itertools import chain, cycle
+
+import numpy as np
+from scipy.spatial.transform import Rotation as RotScipy
+
+from magpylib._src.defaults.defaults_classes import default_settings
+from magpylib._src.defaults.defaults_utility import linearize_dict
+from magpylib._src.input_checks import check_input_zoom
+from magpylib._src.style import get_style
+from magpylib._src.utility import format_obj_input, merge_dicts_with_conflict_check
+
+DEFAULT_ROW_COL_PARAMS = {
+    "row": 1,
+    "col": 1,
+    "output": "model3d",
+    "sumup": True,
+    "pixel_agg": "mean",
+    "in_out": "auto",
+    "zoom": 0,
+    "units_length": "auto",
+}
+
+
+def get_legend_label(obj, style=None, suffix=True):
+    """provides legend entry based on name and suffix"""
+    style = obj.style if style is None else style
+    name = style.label if style.label else obj.__class__.__name__
+    legend_txt = style.legend.text
+    if legend_txt:
+        return legend_txt
+    suff = ""
+    if suffix and style.description.show:
+        desc = style.description.text
+        if not desc:
+            desc = getattr(obj, "_default_style_description", "")
+        if desc:
+            suff = f" ({desc})"
+    return f"{name}{suff}"
+
+
+def place_and_orient_model3d(
+    model_kwargs,
+    *,
+    model_args=None,
+    orientation=None,
+    position=None,
+    coordsargs=None,
+    scale=1,
+    return_model_args=False,
+    return_coordsargs=False,
+    length_factor=1,
+    **kwargs,
+):
+    """places and orients mesh3d dict"""
+    if orientation is None and position is None and length_factor == 1:
+        new_model_kwargs = {**model_kwargs, **kwargs}
+        new_model_args = model_args
+    else:
+        position = (0.0, 0.0, 0.0) if position is None else position
+        position = np.array(position, dtype=float)
+        new_model_dict = {}
+        if model_args is None:
+            model_args = ()
+        new_model_args = list(model_args)
+        vertices, coordsargs, useargs = get_vertices_from_model(
+            model_kwargs, model_args, coordsargs
+        )
+        # sometimes traces come as (n,m,3) shape
+        vert_shape = vertices.shape
+        vertices = np.reshape(vertices.astype(float), (3, -1))
+
+        vertices = vertices.T
+
+        if orientation is not None:
+            vertices = orientation.apply(vertices)
+        new_vertices = (vertices * scale + position).T * length_factor
+        new_vertices = np.reshape(new_vertices, vert_shape)
+        for i, k in enumerate("xyz"):
+            key = coordsargs[k]
+            if useargs:
+                ind = int(key[5])
+                new_model_args[ind] = new_vertices[i]
+            else:
+                new_model_dict[key] = new_vertices[i]
+        new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs}
+
+    out = (new_model_kwargs,)
+    if return_model_args:
+        out += (new_model_args,)
+    if return_coordsargs:
+        out += (coordsargs,)
+    return out[0] if len(out) == 1 else out
+
+
+def get_vertices_from_model(model_kwargs, model_args=None, coordsargs=None):
+    """get vertices from model kwargs and args"""
+    if model_args and coordsargs is None:  # matplotlib default
+        coordsargs = {"x": "args[0]", "y": "args[1]", "z": "args[2]"}
+    vertices = []
+    if coordsargs is None:
+        coordsargs = {"x": "x", "y": "y", "z": "z"}
+    useargs = False
+    for k in "xyz":
+        key = coordsargs[k]
+        if key.startswith("args"):
+            useargs = True
+            ind = int(key[5])
+            v = model_args[ind]
+        elif key in model_kwargs:
+            v = model_kwargs[key]
+        else:
+            msg = (
+                "Rotating/Moving of provided model failed, trace dictionary "
+                f"has no argument {k!r}, use `coordsargs` to specify the names of the "
+                "coordinates to be used.\n"
+                "Matplotlib backends will set up coordsargs automatically if "
+                "the `args=(xs,ys,zs)` argument is provided."
+            )
+            raise ValueError(msg)
+        vertices.append(v)
+
+    vertices = np.array(vertices)
+    return vertices, coordsargs, useargs
+
+
+def draw_arrowed_line(
+    vec,
+    pos,
+    sign=1,
+    arrow_size=0.1,
+    arrow_pos=0.5,
+    pivot="middle",
+    include_line=True,
+) -> tuple:
+    """
+    Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and
+    centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and
+    moved to position `pos`.
+    """
+    norm = np.linalg.norm(vec)
+    nvec = np.array(vec) / norm
+    yaxis = np.array([0, 1, 0])
+    cross = np.cross(nvec, yaxis)
+    dot = np.dot(nvec, yaxis)
+    n = np.linalg.norm(cross)
+    arrow_shift = arrow_pos - 0.5
+    hx = 0.6 * arrow_size
+    hy = np.sign(sign) * arrow_size
+    anchor = (
+        (0, -0.5, 0)
+        if pivot == "tip"
+        else (0, 0.5, 0)
+        if pivot == "tail"
+        else (0, 0, 0)
+    )
+    arrow = [
+        [0, arrow_shift, 0],
+        [-hx, arrow_shift - hy, 0],
+        [0, arrow_shift, 0],
+        [hx, arrow_shift - hy, 0],
+        [0, arrow_shift, 0],
+    ]
+    if include_line:
+        arrow = [[0, -0.5, 0], *arrow, [0, 0.5, 0]]
+    else:
+        arrow = [[0, -0.5, 0], [np.nan] * 3, *arrow, [np.nan] * 3, [0, 0.5, 0]]
+    arrow = (np.array(arrow) + np.array(anchor)) * norm
+
+    if n == 0 and dot == -1:
+        R = RotScipy.from_rotvec([0, 0, np.pi])
+        arrow = R.apply(arrow)
+    elif n != 0:
+        t = np.arccos(dot)
+        R = RotScipy.from_rotvec(-t * cross / n)
+        arrow = R.apply(arrow)
+    return arrow + pos
+
+
+def draw_arrow_from_vertices(
+    vertices, sign, arrow_size, arrow_pos=0.5, scaled=True, include_line=True
+):
+    """returns scatter coordinates of arrows between input vertices"""
+    vectors = np.diff(vertices, axis=0)
+    if scaled:
+        arrow_sizes = [arrow_size * 0.1] * (len(vertices) - 1)
+    else:
+        vec_lens = np.linalg.norm(vectors, axis=1)
+        mask0 = vec_lens == 0
+        vec_lens[mask0] = 1
+        arrow_sizes = arrow_size / vec_lens
+        arrow_sizes[mask0] = 0
+    positions = vertices[:-1] + vectors / 2
+    vertices = np.concatenate(
+        [
+            draw_arrowed_line(
+                vec,
+                pos,
+                sign,
+                arrow_size=size,
+                arrow_pos=arrow_pos,
+                include_line=include_line,
+            ).T
+            for vec, pos, size in zip(vectors, positions, arrow_sizes, strict=False)
+        ],
+        axis=1,
+    )
+
+    return vertices.T
+
+
+def draw_arrow_on_circle(sign, diameter, arrow_size, scaled=True, angle_pos_deg=0):
+    """draws an oriented circle with an arrow"""
+    hy = 0.2 * arrow_size if scaled else arrow_size / diameter * 2
+    hx = 0.6 * hy
+    hy *= np.sign(sign)
+    x = np.array([1 + hx, 1, 1 - hx]) * diameter / 2
+    y = np.array([-hy, 0, -hy]) * diameter / 2
+    z = np.zeros(x.shape)
+    vertices = np.array([x, y, z]).T
+    if angle_pos_deg != 0:
+        rot = RotScipy.from_euler("z", angle_pos_deg, degrees=True)
+        vertices = rot.apply(vertices)
+    return vertices
+
+
+def get_rot_pos_from_path(obj, show_path=None):
+    """
+    subsets orientations and positions depending on `show_path` value.
+    examples:
+    show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6]
+    returns rots[[1,2,6]], poss[[1,2,6]]
+    """
+    # pylint: disable=protected-access
+    # pylint: disable=invalid-unary-operand-type
+    if show_path is None:
+        show_path = True
+    pos = obj._position
+    orient = obj._orientation
+    path_len = pos.shape[0]
+    if show_path is True or show_path is False or show_path == 0:
+        inds = np.array([-1])
+    elif isinstance(show_path, int):
+        inds = np.arange(path_len, dtype=int)[::-show_path]
+    elif hasattr(show_path, "__iter__") and not isinstance(show_path, str):
+        inds = np.array(show_path)
+    else:  # pragma: no cover
+        msg = f"Invalid show_path value ({show_path})"
+        raise ValueError(msg)
+    inds[inds >= path_len] = path_len - 1
+    inds = np.unique(inds)
+    if inds.size == 0:
+        inds = np.array([path_len - 1])
+    rots = orient[inds]
+    poss = pos[inds]
+    return rots, poss, inds
+
+
+def get_objects_props_by_row_col(*objs, colorsequence, style_kwargs):
+    """Return flat dict with objs as keys object properties as values.
+    Properties include: row_cols, style, legendgroup, legendtext"""
+    flat_objs_rc = {}
+    rc_params_by_obj = {}
+    obj_list_semi_flat = [o for obj in objs for o in obj["objects"]]
+    for obj in objs:
+        rc_params = {k: v for k, v in obj.items() if k != "objects"}
+        for subobj in obj["objects"]:
+            children = getattr(subobj, "children_all", [])
+            for child in chain([subobj], children):
+                if child not in rc_params_by_obj:
+                    rc_params_by_obj[child] = []
+                rc_params_by_obj[child].append(rc_params)
+    flat_sub_objs = get_flatten_objects_properties_recursive(
+        *obj_list_semi_flat,
+        style_kwargs=style_kwargs,
+        colorsequence=colorsequence,
+    )
+    for obj, rc_params_list in rc_params_by_obj.items():
+        for rc_params in rc_params_list:
+            rc = rc_params["row"], rc_params["col"]
+            if rc not in flat_objs_rc:
+                flat_objs_rc[rc] = {"objects": {}, "rc_params": rc_params}
+            flat_objs_rc[rc]["objects"][obj] = flat_sub_objs[obj]
+    return flat_objs_rc
+
+
+def get_flatten_objects_properties_recursive(
+    *obj_list_semi_flat,
+    style_kwargs=None,
+    colorsequence=None,
+    color_cycle=None,
+    parent_legendgroup=None,
+    parent_color=None,
+    parent_label=None,
+    parent_showlegend=None,
+):
+    """returns a flat dict -> (obj: display_props, ...) from nested collections"""
+    if color_cycle is None:
+        color_cycle = cycle(colorsequence)
+    flat_objs = {}
+    for subobj in dict.fromkeys(obj_list_semi_flat):
+        isCollection = getattr(subobj, "children", None) is not None
+        style_kwargs = {} if style_kwargs is None else style_kwargs
+        style = get_style(subobj, default_settings, **style_kwargs)
+        if style.label is None:
+            style.label = str(type(subobj).__name__)
+        legendgroup = f"{subobj}" if parent_legendgroup is None else parent_legendgroup
+        label = (
+            get_legend_label(subobj, style=style)
+            if parent_label is None
+            else parent_label
+        )
+        if parent_color is not None and style.color is None:
+            style.color = parent_color
+        elif style.color is None:
+            style.color = next(color_cycle)
+        flat_objs[subobj] = {
+            "legendgroup": legendgroup,
+            "style": style,
+            "legendtext": label,
+            "showlegend": parent_showlegend,
+        }
+        if isCollection:
+            new_ojbs = get_flatten_objects_properties_recursive(
+                *subobj.children,
+                colorsequence=colorsequence,
+                color_cycle=color_cycle,
+                parent_legendgroup=legendgroup,
+                parent_color=style.color,
+                parent_label=label,
+                parent_showlegend=style.legend.show,
+                style_kwargs=style_kwargs,
+            )
+            flat_objs = {**new_ojbs, **flat_objs}
+    return flat_objs
+
+
+def merge_mesh3d(*traces):
+    """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate
+    the indices of each object in order to point to the right vertices in the concatenated
+    vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also
+    get concatenated if they are present in all objects. All other parameter found in the
+    dictionary keys are taken from the first object, other keys from further objects are ignored.
+    """
+    merged_trace = {}
+    L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum()
+    for k in "ijk":
+        if k in traces[0]:
+            merged_trace[k] = np.hstack(
+                [b[k] + m for b, m in zip(traces, L, strict=False)]
+            )
+    for k in "xyz":
+        merged_trace[k] = np.concatenate([b[k] for b in traces])
+    for k in ("intensity", "facecolor"):
+        if k in traces[0] and traces[0][k] is not None:
+            merged_trace[k] = np.hstack([b[k] for b in traces])
+    for k, v in traces[0].items():
+        if k not in merged_trace:
+            merged_trace[k] = v
+    return merged_trace
+
+
+def merge_scatter3d(*traces):
+    """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a
+    `None` vertex to prevent line connection between objects to be concatenated. Keys are taken
+    from the first object, other keys from further objects are ignored.
+    """
+    if len(traces) == 1:
+        return traces[0]
+    mode = traces[0].get("mode")
+    mode = "" if mode is None else mode
+    if not mode:
+        traces[0]["mode"] = "markers"
+    no_gap = "line" not in mode
+
+    merged_trace = {}
+    for k in "xyz":
+        if no_gap:
+            stack = [b[k] for b in traces]
+        else:
+            stack = [pts for b in traces for pts in [[None], b[k]]]
+        merged_trace[k] = np.hstack(stack)
+    for k, v in traces[0].items():
+        if k not in merged_trace:
+            merged_trace[k] = v
+    return merged_trace
+
+
+def aggregate_by_trace_type(traces):
+    """aggregate traces by type"""
+    result_dict = defaultdict(list)
+    for item in traces:
+        result_dict[item["type"]].append(item)
+    yield from result_dict.items()
+
+
+def merge_traces(*traces):
+    """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`.
+    All traces have be of the same type when merging. Keys are taken from the first object, other
+    keys from further objects are ignored.
+    """
+    new_traces = []
+    for ttype, tlist in aggregate_by_trace_type(traces):
+        if len(tlist) > 1:
+            if ttype == "mesh3d":
+                new_traces.append(merge_mesh3d(*tlist))
+            elif ttype == "scatter3d":
+                new_traces.append(merge_scatter3d(*tlist))
+            else:  # pragma: no cover
+                new_traces.extend(tlist)
+        elif len(tlist) == 1:
+            new_traces.append(tlist[0])
+    return new_traces
+
+
+def getIntensity(vertices, axis) -> np.ndarray:
+    """Calculates the intensity values for vertices based on the distance of the vertices to
+    the mean vertices position in the provided axis direction. It can be used for plotting
+    fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/
+
+    Parameters
+    ----------
+    vertices : ndarray, shape (n,3)
+        The n vertices of the mesh object.
+    axis : ndarray, shape (3,)
+        Direction vector.
+
+    Returns
+    -------
+    Intensity values: ndarray, shape (n,)
+    """
+    p = np.array(vertices).T
+    pos = np.mean(p, axis=1)
+    m = np.array(axis)
+    intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2]
+    # normalize to interval [0,1] (necessary for when merging mesh3d traces)
+    ptp = np.ptp(intensity)
+    ptp = ptp if ptp != 0 else 1
+    return (intensity - np.min(intensity)) / ptp
+
+
+@lru_cache(maxsize=32)
+def getColorscale(
+    color_transition=0,
+    color_north="#E71111",  # 'red'
+    color_middle="#DDDDDD",  # 'grey'
+    color_south="#00B050",  # 'green'
+):
+    """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array
+    containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named
+    color string. At minimum, a mapping for the lowest (0) and highest (1) values is required.
+    For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale
+    is created depending on the north/middle/south poles colors. If the middle color is
+    None, the colorscale will only have north and south pole colors.
+
+    Parameters
+    ----------
+    color_transition : float, default=0.1
+        A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors
+        visualization.
+    color_north : str, default=None
+        Magnetic north pole color.
+    color_middle : str, default=None
+        Color of area between south and north pole.
+    color_south : str, default=None
+        Magnetic north pole color.
+
+    Returns
+    -------
+    colorscale: list
+        Colorscale as list of tuples.
+    """
+    if color_middle is False:
+        colorscale = (
+            (0.0, color_south),
+            (0.5 * (1 - color_transition), color_south),
+            (0.5 * (1 + color_transition), color_north),
+            (1, color_north),
+        )
+    else:
+        colorscale = (
+            (0.0, color_south),
+            (0.2 - 0.2 * (color_transition), color_south),
+            (0.2 + 0.3 * (color_transition), color_middle),
+            (0.8 - 0.3 * (color_transition), color_middle),
+            (0.8 + 0.2 * (color_transition), color_north),
+            (1.0, color_north),
+        )
+    return colorscale
+
+
+def get_scene_ranges(*traces, zoom=0) -> np.ndarray:
+    """
+    Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be
+    any plotly trace object or a dict, with x,y,z numbered parameters.
+    """
+    ranges_rc = {}
+    tr_dim_count = {}
+    for tr_item in traces:
+        tr = tr_item
+        coords = "xyz"
+        rc = tr.get("row", 1), tr.get("col", 1)
+        if "constructor" in tr:
+            verts, *_ = get_vertices_from_model(
+                model_args=tr.get("args", None),
+                model_kwargs=tr.get("kwargs", None),
+                coordsargs=tr.get("coordsargs", None),
+            )
+            kwex = tr["kwargs_extra"]
+            tr = dict(zip("xyz", verts, strict=False))
+            rc = kwex["row"], kwex["col"]
+        if rc not in ranges_rc:
+            ranges_rc[rc] = {k: [] for k in "xyz"}
+            tr_dim_count[rc] = {"2D": 0, "3D": 0}
+        if "z" not in tr:  # only extend range for 3d traces
+            tr_dim_count[rc]["2D"] += 1
+        else:
+            tr_dim_count[rc]["3D"] += 1
+            pts = np.array([tr[k] for k in coords], dtype="float64").T
+            try:  # for mesh3d, use only vertices part of faces for range calculation
+                inds = np.array([tr[k] for k in "ijk"], dtype="int64").T
+                pts = pts[inds]
+            except KeyError:
+                # for 2d meshes, nothing special needed
+                pass
+            pts = pts.reshape(-1, 3)
+            if pts.size != 0:
+                min_max = np.nanmin(pts, axis=0), np.nanmax(pts, axis=0)
+                for v, min_, max_ in zip(
+                    ranges_rc[rc].values(), *min_max, strict=False
+                ):
+                    v.extend([min_, max_])
+    for rc, ranges_item in ranges_rc.items():
+        ranges = ranges_item
+        if tr_dim_count[rc]["3D"]:
+            zo = zoom[rc] if isinstance(zoom, dict) else zoom
+            # SET 3D PLOT BOUNDARIES
+            # collect min/max from all elements
+            r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()])
+            size = np.diff(r, axis=1)
+            m = size.max() / 2
+            m = 1 if m == 0 else m
+            center = r.mean(axis=1)
+            ranges = np.array([center - m * (1 + zo), center + m * (1 + zo)]).T
+        else:
+            ranges = np.array([[-1.0, 1.0]] * 3)
+        ranges_rc[rc] = ranges
+    if not ranges_rc:
+        ranges_rc[(1, 1)] = np.array([[-1.0, 1.0]] * 3)
+    return ranges_rc
+
+
+def rescale_traces(traces, factors):
+    """Rescale traces based on scale factors by (row,col) index"""
+    for ind, tr in enumerate(traces):
+        if "constructor" in tr:
+            kwex = tr["kwargs_extra"]
+            rc = kwex["row"], kwex["col"]
+            kwargs, args = place_and_orient_model3d(
+                model_kwargs=tr.get("kwargs", None),
+                model_args=tr.get("args", None),
+                coordsargs=tr.get("coordsargs", None),
+                length_factor=factors[rc],
+                return_model_args=True,
+            )
+            tr["kwargs"].update(kwargs)
+            tr["args"] = args
+        if "z" in tr:  # rescale only 3d traces
+            rc = tr.get("row", 1), tr.get("col", 1)
+            traces[ind] = place_and_orient_model3d(tr, length_factor=factors[rc])
+    return traces
+
+
+def group_traces(*traces):
+    """Group and merge mesh traces with similar properties. This drastically improves
+    browser rendering performance when displaying a lot of mesh3d objects."""
+    mesh_groups = {}
+    common_keys = ["legendgroup", "opacity", "row", "col", "color"]
+    spec_keys = {
+        "mesh3d": ["colorscale", "color", "facecolor"],
+        "scatter3d": [
+            "marker",
+            "line_dash",
+            "line_color",
+            "line_width",
+            "marker_color",
+            "marker_symbol",
+            "marker_size",
+            "mode",
+        ],
+    }
+    for tr_item in traces:
+        tr = tr_item
+        tr = linearize_dict(
+            tr,
+            separator="_",
+        )
+        gr = [tr["type"]]
+        for k in [*common_keys, *spec_keys.get(tr["type"], [])]:
+            v = tr.get(k, None) is None if k == "facecolor" else tr.get(k, "")
+            gr.append(str(v))
+        gr = "".join(gr)
+        if gr not in mesh_groups:
+            mesh_groups[gr] = []
+        mesh_groups[gr].append(tr)
+
+    traces = []
+    for group in mesh_groups.values():
+        traces.extend(merge_traces(*group))
+    return traces
+
+
+def subdivide_mesh_by_facecolor(trace):
+    """Subdivide a mesh into a list of meshes based on facecolor"""
+    facecolor = trace["facecolor"] = np.array(trace["facecolor"])
+    subtraces = []
+    # pylint: disable=singleton-comparison
+    facecolor[facecolor == np.array(None)] = "black"
+    for color in np.unique(facecolor):
+        mask = facecolor == color
+        new_trace = trace.copy()
+        uniq = np.unique(np.hstack([np.array(trace[k])[mask] for k in "ijk"]))
+        new_inds = np.arange(len(uniq))
+        mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype)
+        mapping_ar[uniq] = new_inds
+        for k in "ijk":
+            new_trace[k] = mapping_ar[np.array(trace[k])[mask]]
+        for k in "xyz":
+            new_trace[k] = new_trace[k][uniq]
+        new_trace["color"] = color
+        new_trace.pop("facecolor")
+        subtraces.append(new_trace)
+    return subtraces
+
+
+def process_show_input_objs(objs, **kwargs):
+    """Extract max_rows and max_cols from obj list of dicts"""
+    defaults = DEFAULT_ROW_COL_PARAMS.copy()
+    identifiers = ("row", "col")
+    unique_fields = tuple(k for k in defaults if k not in identifiers)
+    sources_and_sensors_only = []
+    new_objs = []
+    for obj_item in objs:
+        obj = obj_item
+        # add missing kwargs
+        if isinstance(obj, dict):
+            obj = {**defaults, **obj, **kwargs}
+        else:
+            obj = {**defaults, "objects": obj, **kwargs}
+
+        # extend objects list
+        obj["objects"] = format_obj_input(
+            obj["objects"], allow="sources+sensors+collections"
+        )
+        sources_and_sensors_only.extend(
+            format_obj_input(obj["objects"], allow="sources+sensors")
+        )
+        new_objs.append(obj)
+
+    row_col_dict = merge_dicts_with_conflict_check(
+        new_objs,
+        target="objects",
+        identifiers=identifiers,
+        unique_fields=unique_fields,
+    )
+
+    # create subplot specs grid
+    row_cols = [*row_col_dict]
+    max_rows, max_cols = np.max(row_cols, axis=0).astype(int) if row_cols else (1, 1)
+    # convert to int to avoid np.int32 type conflicting with plolty subplot specs
+    max_rows, max_cols = int(max_rows), int(max_cols)
+    specs = np.array([[{"type": "scene"}] * max_cols] * max_rows)
+    for rc, obj in row_col_dict.items():
+        if obj["output"] != "model3d":
+            specs[rc[0] - 1, rc[1] - 1] = {"type": "xy"}
+    if max_rows == 1 and max_cols == 1:
+        max_rows = max_cols = None
+
+    for obj in row_col_dict.values():
+        check_input_zoom(obj.get("zoom", None))
+
+    return (
+        list(row_col_dict.values()),
+        list(dict.fromkeys(sources_and_sensors_only)),
+        max_rows,
+        max_cols,
+        specs,
+    )
+
+
+def triangles_area(triangles):
+    """Return area of triangles of shape (n,3,3) into an array of shape n"""
+    norm = np.cross(
+        triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0], axis=1
+    )
+    return np.linalg.norm(norm, axis=1) / 2
+
+
+def slice_mesh_with_plane(
+    verts, tris, plane_orig=(0.0, 0.0, 0.0), plane_axis=(1.0, 0.0, 0.0)
+):
+    """Slice a mesh obj defined by vertices an triangles by a plane defined by its
+    origin and axis. Returns two (verts, tris) tuples for left and right side."""
+    dists = np.dot(verts - plane_orig, plane_axis)
+
+    if np.any(dists == 0):
+        # if planes passes some vertices shift vertices slightly
+        # IMPROVE-> make special case without a hack like this
+        verts += np.array([129682, -986394, 123495]) * 1e-16
+        dists = np.dot(verts - plane_orig, plane_axis)
+    all_dists = dists[tris]
+
+    mask_left = np.all(all_dists < 0, axis=1)
+    mask_right = np.all(all_dists > 0, axis=1)
+    mask_cut = np.any(all_dists < 0, axis=1) & np.any(all_dists > 0, axis=1)
+    tri_cut = mask_cut.nonzero()[0]
+
+    d = all_dists.copy()[mask_cut]
+    t = tris.copy()[tri_cut]
+
+    s = d[:, [0, 1, 1, 2, 2, 0]].reshape(-1, 3, 2)  # pairs of distances
+
+    # make sure the first two edges are the one intersected, if not cycle it
+    im = np.prod(s, axis=2) < 0  # edge intersects if product of dist<0
+    m1 = im[:, [0, 2]].sum(axis=1) == 2
+    m2 = im[:, [1, 2]].sum(axis=1) == 2
+    if np.any(m1):
+        t[m1] = t[m1][:, [2, 0, 1]]
+        s[m1] = s[m1][:, [2, 0, 1]]
+        d[m1] = d[m1][:, [2, 0, 1]]
+    if np.any(m2):
+        t[m2] = t[m2][:, [1, 2, 0]]
+        s[m2] = s[m2][:, [1, 2, 0]]
+        d[m2] = d[m2][:, [1, 2, 0]]
+    f = verts[t]
+
+    p = np.abs(s).sum(axis=2)  # projected dists to plane
+
+    e = f[:, [0, 1, 1, 2, 2, 0]].reshape(-1, 3, 2, 3)  # edges
+    v = np.squeeze(np.diff(e, axis=2))  # edges vectors
+
+    pts = (f + v * (np.abs(d) / p).reshape(-1, 3, 1))[:, :2]
+
+    f5 = np.concatenate([f, pts], axis=1)
+    f1 = f5[:, [[3, 1, 4]]]
+    f2 = f5[:, [[0, 3, 2], [3, 4, 2]]]
+
+    fl1 = f1[d[:, 0] > 0].reshape(-1, 3, 3)
+    fr1 = f1[d[:, 0] < 0].reshape(-1, 3, 3)
+    fl2 = f2[d[:, 0] < 0].reshape(-1, 3, 3)
+    fr2 = f2[d[:, 0] > 0].reshape(-1, 3, 3)
+
+    fl0 = verts[tris[mask_left]]
+    fr0 = verts[tris[mask_right]]
+
+    fl = np.concatenate([fl0, fl1, fl2]).reshape((-1, 3))
+    fr = np.concatenate([fr0, fr1, fr2]).reshape((-1, 3))
+
+    vr, tr = np.unique(fr, axis=0, return_inverse=True)
+    tr = tr.reshape((-1, 3))
+
+    vl, tl = np.unique(fl, axis=0, return_inverse=True)
+    tl = tl.reshape((-1, 3))
+    return (vl, tl), (vr, tr)
+
+
+def slice_mesh_from_colorscale(trace, axis, colorscale):
+    """Slice mesh3d obj by axis and colorsale. Return single mesh dict with according
+    facecolor argument."""
+    cs = colorscale
+    origs = np.array(list(dict.fromkeys([v[0] for v in cs])))[1:-1]
+    colors = list(dict.fromkeys([v[1] for v in cs]))
+    vr = np.array([v for k, v in trace.items() if k in "xyz"]).T
+    tr = np.array([v for k, v in trace.items() if k in "ijk"]).T
+    axis = axis / np.linalg.norm(axis)
+    dists = np.dot(vr + np.mean(vr, axis=0), axis)
+    ptp = np.ptp(dists)
+    shift = np.mean([vr[np.argmin(dists)], vr[np.argmax(dists)]], axis=0)
+    origs = np.vstack((origs - 0.5) * ptp) * axis + shift
+
+    traces = []
+    for ind, color in enumerate(colors):
+        if ind < len(origs):
+            (vl, tl), (vr, tr) = slice_mesh_with_plane(vr, tr, origs[ind], axis)
+        else:
+            vl, tl = vr, tr
+        trace_temp = dict(zip("xyzijk", [*vl.T, *tl.T], strict=False))
+        trace_temp.update(facecolor=np.array([color] * len(tl)))
+        traces.append(trace_temp)
+    return {**trace, **merge_mesh3d(*traces)}
+
+
+def create_null_dim_trace(color=None, **kwargs):
+    """Returns a simple trace with single markers"""
+    trace = {
+        "type": "scatter3d",
+        "x": [0],
+        "y": [0],
+        "z": [0],
+        "mode": "markers",
+        "marker_size": 10,
+    }
+    if color is not None:
+        trace["marker_color"] = color
+    return {**trace, **kwargs}
diff --git a/src/magpylib/_src/exceptions.py b/src/magpylib/_src/exceptions.py
new file mode 100644
index 000000000..30d0ff278
--- /dev/null
+++ b/src/magpylib/_src/exceptions.py
@@ -0,0 +1,23 @@
+"""Definition of custom exceptions"""
+
+from __future__ import annotations
+
+
+class MagpylibBadUserInput(Exception):
+    """bad user input"""
+
+
+class MagpylibInternalError(Exception):
+    """should never have reached this position in the code"""
+
+
+class MagpylibBadInputShape(Exception):
+    """catching bad input shapes"""
+
+
+class MagpylibMissingInput(Exception):
+    """catching missing user inputs"""
+
+
+class MagpylibDeprecationWarning(Warning):
+    """Non-suppressed Deprecation Warning."""
diff --git a/src/magpylib/_src/fields/__init__.py b/src/magpylib/_src/fields/__init__.py
new file mode 100644
index 000000000..71eb106b6
--- /dev/null
+++ b/src/magpylib/_src/fields/__init__.py
@@ -0,0 +1,8 @@
+"""_src.fields"""
+
+from __future__ import annotations
+
+__all__ = ["getB", "getH", "getJ", "getM"]
+
+# create interface to outside of package
+from magpylib._src.fields.field_wrap_BH import getB, getH, getJ, getM
diff --git a/src/magpylib/_src/fields/field_BH_circle.py b/src/magpylib/_src/fields/field_BH_circle.py
new file mode 100644
index 000000000..9ee4fc062
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_circle.py
@@ -0,0 +1,158 @@
+"""
+Implementations of analytical expressions for the magnetic field of
+a circular current loop. Computation details in function docstrings.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.fields.special_cel import cel_iter
+from magpylib._src.input_checks import check_field_input
+from magpylib._src.utility import cart_to_cyl_coordinates, cyl_field_to_cart
+
+
+# CORE
+def current_circle_Hfield(
+    r0: np.ndarray,
+    r: np.ndarray,
+    z: np.ndarray,
+    i0: np.ndarray,
+) -> np.ndarray:
+    """B-field of a circular line-current loops in Cylindrical Coordinates.
+
+    The loops lies in the z=0 plane with the coordinate origin at their centers.
+    The output is proportional to the electrical currents i0, and independent of
+    the length units chosen for observers and loop radii.
+
+    Parameters
+    ----------
+    r0: ndarray, shape (n,)
+        Radii of loops.
+
+    r: ndarray, shape (n,)
+        Radial positions of observers.
+
+    z: ndarray, shape (n,)
+        Axial positions of observers.
+
+    i0: ndarray, shape (n,)
+        Electrical currents.
+
+    Returns
+    -------
+    B-field: ndarray, shape (3,n)
+        B-field generated by Loops at observer positions in cylinder coordinates.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> H = magpy.core.current_circle_Hfield(
+    ...    r0=np.array([1,2]),
+    ...    r =np.array([1,1]),
+    ...    z =np.array([1,2]),
+    ...    i0=np.array([1,3]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [[0.091 0.094]
+     [0.    0.   ]
+     [0.077 0.226]]
+
+    Notes
+    -----
+    Implementation based on "Numerically stable and computationally
+    efficient expression for the magnetic field of a current loop.", M.Ortner et al,
+    Magnetism 2023, 3(1), 11-31.
+    """
+    n5 = len(r)
+
+    # express through ratios (make dimensionless, avoid large/small input values, stupid)
+    r = r / r0
+    z = z / r0
+
+    # field computation from paper
+    z2 = z**2
+    x0 = z2 + (r + 1) ** 2
+    k2 = 4 * r / x0
+    q2 = (z2 + (r - 1) ** 2) / x0
+
+    k = np.sqrt(k2)
+    q = np.sqrt(q2)
+    p = 1 + q
+    pf = k / np.sqrt(r) / q2 / 20 / r0 * 1e-6 * i0
+
+    # cel* part
+    cc = k2 * k2
+    ss = 2 * cc * q / p
+    Hr = pf * z / r * cel_iter(q, p, np.ones(n5), cc, ss, p, q)
+
+    # cel** part
+    cc = k2 * (k2 - (q2 + 1) / r)
+    ss = 2 * k2 * q * (k2 / p - p / r)
+    Hz = -pf * cel_iter(q, p, np.ones(n5), cc, ss, p, q)
+
+    # input is I -> output must be H-field
+    return np.vstack((Hr, np.zeros(n5), Hz)) * 795774.7154594767  # *1e7/4/np.pi
+
+
+def BHJM_circle(
+    field: str,
+    observers: np.ndarray,
+    diameter: np.ndarray,
+    current: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate circle core to BHJM
+    - treat special cases
+    """
+
+    # allocate
+    BHJM = np.zeros_like(observers, dtype=float)
+
+    check_field_input(field)
+    if field in "MJ":
+        return BHJM
+
+    r, phi, z = cart_to_cyl_coordinates(observers)
+    r0 = np.abs(diameter / 2)
+
+    # Special cases:
+    # case1: loop radius is 0 -> return (0,0,0)
+    mask1 = r0 == 0
+    # case2: at singularity -> return (0,0,0)
+    mask2 = np.logical_and(abs(r - r0) < 1e-15 * r0, z == 0)
+    # case3: r=0
+    mask3 = r == 0
+    if np.any(mask3):
+        mask4 = mask3 * ~mask1  # only relevant if not also case1
+        BHJM[mask4, 2] = (
+            (r0[mask4] ** 2 / (z[mask4] ** 2 + r0[mask4] ** 2) ** (3 / 2))
+            * current[mask4]
+            * 0.5
+        )
+
+    # general case
+    mask5 = ~np.logical_or(np.logical_or(mask1, mask2), mask3)
+    if np.any(mask5):
+        BHJM[mask5] = current_circle_Hfield(
+            r0=r0[mask5],
+            r=r[mask5],
+            z=z[mask5],
+            i0=current[mask5],
+        ).T
+
+    BHJM[:, 0], BHJM[:, 1] = cyl_field_to_cart(phi, BHJM[:, 0])
+
+    if field == "H":
+        return BHJM
+
+    if field == "B":
+        return BHJM * MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(  # pragma: no cover
+        msg
+    )
diff --git a/src/magpylib/_src/fields/field_BH_cuboid.py b/src/magpylib/_src/fields/field_BH_cuboid.py
new file mode 100644
index 000000000..d79a47b2f
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_cuboid.py
@@ -0,0 +1,281 @@
+"""
+Implementations of analytical expressions for the magnetic field of homogeneously
+magnetized Cuboids. Computation details in function docstrings.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.input_checks import check_field_input
+
+# pylint: disable=too-many-statements
+
+
+# CORE
+def magnet_cuboid_Bfield(
+    observers: np.ndarray,
+    dimensions: np.ndarray,
+    polarizations: np.ndarray,
+):
+    """B-field of homogeneously magnetized cuboids in Cartesian Coordinates.
+
+    The cuboids sides are parallel to the coordinate axes. The geometric centers of the
+    cuboids lie in the origin. The output is proportional to the polarization magnitude
+    and independent of the length units chosen for observers and dimensions.
+
+    Parameters
+    ----------
+    observers: ndarray, shape (n,3)
+        Observer positions (x,y,z) in Cartesian coordinates.
+
+    dimensions: ndarray, shape (n,3)
+        Length of cuboids sides.
+
+    polarizations: ndarray, shape (n,3)
+        Magnetic polarization vectors.
+
+    Returns
+    -------
+    B-Field: ndarray, shape (n,3)
+        B-field generated by Cuboids at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.magnet_cuboid_Bfield(
+    ...    observers=np.array([(1,1,1), (2,2,2)]),
+    ...    dimensions=np.array([(1,1,1), (1,2,3)]),
+    ...    polarizations=np.array([(0,0,1), (.5,.5,0)]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[ 1.561e-02  1.561e-02 -3.534e-17]
+     [ 7.732e-03  6.544e-03  1.048e-02]]
+
+    Notes
+    -----
+    Field computations via magnetic surface charge density. Published
+    several times with similar expressions:
+
+    Yang: Superconductor Science and Technology 3(12):591 (1990)
+
+    Engel-Herbert: Journal of Applied Physics 97(7):074504 - 074504-4 (2005)
+
+    Camacho: Revista Mexicana de Fisica E 59 (2013) 8-17
+
+    Avoiding indeterminate forms:
+
+    In the above implementations there are several indeterminate forms
+    where the limit must be taken. These forms appear at positions
+    that are extensions of the edges in all xyz-octants except bottQ4.
+    In the vicinity of these indeterminate forms the formula becomes
+    numerically instable.
+
+    Chosen solution: use symmetries of the problem to change all
+    positions to their bottQ4 counterparts. see also
+
+    Cichon: IEEE Sensors Journal, vol. 19, no. 7, April 1, 2019, p.2509
+    """
+    pol_x, pol_y, pol_z = polarizations.T
+    a, b, c = dimensions.T / 2
+    x, y, z = np.copy(observers).T
+
+    # avoid indeterminate forms by evaluating in bottQ4 only --------
+    # basic masks
+    maskx = x < 0
+    masky = y > 0
+    maskz = z > 0
+
+    # change all positions to their bottQ4 counterparts
+    x[maskx] = x[maskx] * -1
+    y[masky] = y[masky] * -1
+    z[maskz] = z[maskz] * -1
+
+    # create sign flips for position changes
+    qsigns = np.ones((len(pol_x), 3, 3))
+    qs_flipx = np.array([[1, -1, -1], [-1, 1, 1], [-1, 1, 1]])
+    qs_flipy = np.array([[1, -1, 1], [-1, 1, -1], [1, -1, 1]])
+    qs_flipz = np.array([[1, 1, -1], [1, 1, -1], [-1, -1, 1]])
+    # signs flips can be applied subsequently
+    qsigns[maskx] = qsigns[maskx] * qs_flipx
+    qsigns[masky] = qsigns[masky] * qs_flipy
+    qsigns[maskz] = qsigns[maskz] * qs_flipz
+
+    # field computations --------------------------------------------
+    # Note: in principle the computation for all three polarization-components can be
+    #   vectorized itself using symmetries. However, tiling the three
+    #   components will cost more than is gained by the vectorized evaluation
+
+    # Note: making the following computation steps is not necessary
+    #   as mkl will cache such small computations
+    xma, xpa = x - a, x + a
+    ymb, ypb = y - b, y + b
+    zmc, zpc = z - c, z + c
+
+    xma2, xpa2 = xma**2, xpa**2
+    ymb2, ypb2 = ymb**2, ypb**2
+    zmc2, zpc2 = zmc**2, zpc**2
+
+    mmm = np.sqrt(xma2 + ymb2 + zmc2)
+    pmp = np.sqrt(xpa2 + ymb2 + zpc2)
+    pmm = np.sqrt(xpa2 + ymb2 + zmc2)
+    mmp = np.sqrt(xma2 + ymb2 + zpc2)
+    mpm = np.sqrt(xma2 + ypb2 + zmc2)
+    ppp = np.sqrt(xpa2 + ypb2 + zpc2)
+    ppm = np.sqrt(xpa2 + ypb2 + zmc2)
+    mpp = np.sqrt(xma2 + ypb2 + zpc2)
+
+    with np.errstate(divide="ignore", invalid="ignore"):
+        ff2x = np.log((xma + mmm) * (xpa + ppm) * (xpa + pmp) * (xma + mpp)) - np.log(
+            (xpa + pmm) * (xma + mpm) * (xma + mmp) * (xpa + ppp)
+        )
+
+        ff2y = np.log(
+            (-ymb + mmm) * (-ypb + ppm) * (-ymb + pmp) * (-ypb + mpp)
+        ) - np.log((-ymb + pmm) * (-ypb + mpm) * (ymb - mmp) * (ypb - ppp))
+
+        ff2z = np.log(
+            (-zmc + mmm) * (-zmc + ppm) * (-zpc + pmp) * (-zpc + mpp)
+        ) - np.log((-zmc + pmm) * (zmc - mpm) * (-zpc + mmp) * (zpc - ppp))
+
+    ff1x = (
+        np.arctan2((ymb * zmc), (xma * mmm))
+        - np.arctan2((ymb * zmc), (xpa * pmm))
+        - np.arctan2((ypb * zmc), (xma * mpm))
+        + np.arctan2((ypb * zmc), (xpa * ppm))
+        - np.arctan2((ymb * zpc), (xma * mmp))
+        + np.arctan2((ymb * zpc), (xpa * pmp))
+        + np.arctan2((ypb * zpc), (xma * mpp))
+        - np.arctan2((ypb * zpc), (xpa * ppp))
+    )
+
+    ff1y = (
+        np.arctan2((xma * zmc), (ymb * mmm))
+        - np.arctan2((xpa * zmc), (ymb * pmm))
+        - np.arctan2((xma * zmc), (ypb * mpm))
+        + np.arctan2((xpa * zmc), (ypb * ppm))
+        - np.arctan2((xma * zpc), (ymb * mmp))
+        + np.arctan2((xpa * zpc), (ymb * pmp))
+        + np.arctan2((xma * zpc), (ypb * mpp))
+        - np.arctan2((xpa * zpc), (ypb * ppp))
+    )
+
+    ff1z = (
+        np.arctan2((xma * ymb), (zmc * mmm))
+        - np.arctan2((xpa * ymb), (zmc * pmm))
+        - np.arctan2((xma * ypb), (zmc * mpm))
+        + np.arctan2((xpa * ypb), (zmc * ppm))
+        - np.arctan2((xma * ymb), (zpc * mmp))
+        + np.arctan2((xpa * ymb), (zpc * pmp))
+        + np.arctan2((xma * ypb), (zpc * mpp))
+        - np.arctan2((xpa * ypb), (zpc * ppp))
+    )
+
+    # contributions from x-polarization
+    #    the 'missing' third sign is hidden in ff1x
+    bx_pol_x = pol_x * ff1x * qsigns[:, 0, 0]
+    by_pol_x = pol_x * ff2z * qsigns[:, 0, 1]
+    bz_pol_x = pol_x * ff2y * qsigns[:, 0, 2]
+    # contributions from y-polarization
+    bx_pol_y = pol_y * ff2z * qsigns[:, 1, 0]
+    by_pol_y = pol_y * ff1y * qsigns[:, 1, 1]
+    bz_pol_y = -pol_y * ff2x * qsigns[:, 1, 2]
+    # contributions from z-polarization
+    bx_pol_z = pol_z * ff2y * qsigns[:, 2, 0]
+    by_pol_z = -pol_z * ff2x * qsigns[:, 2, 1]
+    bz_pol_z = pol_z * ff1z * qsigns[:, 2, 2]
+
+    # summing all contributions
+    bx_tot = bx_pol_x + bx_pol_y + bx_pol_z
+    by_tot = by_pol_x + by_pol_y + by_pol_z
+    bz_tot = bz_pol_x + bz_pol_y + bz_pol_z
+
+    # B = np.c_[bx_tot, by_tot, bz_tot]      # faster for 10^5 and more evaluations
+    B = np.concatenate(((bx_tot,), (by_tot,), (bz_tot,)), axis=0).T
+
+    B /= 4 * np.pi
+    return B
+
+
+def BHJM_magnet_cuboid(
+    field: str,
+    observers: np.ndarray,
+    dimension: np.ndarray,
+    polarization: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate cuboid core field to BHJM
+    - treat special cases
+    """
+
+    RTOL_SURFACE = 1e-15  # relative distance tolerance to be considered on surface
+
+    check_field_input(field)
+
+    pol_x, pol_y, pol_z = polarization.T
+    a, b, c = np.abs(dimension.T) / 2
+    x, y, z = observers.T
+
+    # allocate for output
+    BHJM = polarization.astype(float)
+
+    # SPECIAL CASE 1: polarization = (0,0,0)
+    mask_pol_not_null = ~(
+        (pol_x == 0) * (pol_y == 0) * (pol_z == 0)
+    )  # 2x faster than np.all()
+
+    # SPECIAL CASE 2: 0 in dimension
+    mask_dim_not_null = (a * b * c).astype(bool)
+
+    # SPECIAL CASE 3: observer lies on-edge/corner
+    #   EPSILON to account for numerical imprecision when e.g. rotating
+    #   /a /b /c to account for the missing scaling (EPSILON is large when
+    #    a is e.g. EPSILON itself)
+
+    # on-surface is not a special case
+    mask_surf_x = abs(x_dist := abs(x) - a) < RTOL_SURFACE * a  # on surface
+    mask_surf_y = abs(y_dist := abs(y) - b) < RTOL_SURFACE * b  # on surface
+    mask_surf_z = abs(z_dist := abs(z) - c) < RTOL_SURFACE * c  # on surface
+
+    # inside-outside
+    mask_inside_x = x_dist < RTOL_SURFACE * a
+    mask_inside_y = y_dist < RTOL_SURFACE * b
+    mask_inside_z = z_dist < RTOL_SURFACE * c
+    mask_inside = mask_inside_x & mask_inside_y & mask_inside_z
+
+    # on edge (requires on-surface and inside-outside)
+    mask_xedge = mask_surf_y & mask_surf_z & mask_inside_x
+    mask_yedge = mask_surf_x & mask_surf_z & mask_inside_y
+    mask_zedge = mask_surf_x & mask_surf_y & mask_inside_z
+    mask_not_edge = ~(mask_xedge | mask_yedge | mask_zedge)
+
+    mask_gen = mask_pol_not_null & mask_dim_not_null & mask_not_edge
+
+    if field == "J":
+        BHJM[~mask_inside] = 0
+        return BHJM
+
+    if field == "M":
+        BHJM[~mask_inside] = 0
+        return BHJM / MU0
+
+    BHJM *= 0  # return (0,0,0) for all special cases
+    BHJM[mask_gen] = magnet_cuboid_Bfield(
+        observers=observers[mask_gen],
+        dimensions=dimension[mask_gen],
+        polarizations=polarization[mask_gen],
+    )
+    if field == "B":
+        return BHJM
+
+    if field == "H":
+        BHJM[mask_inside] -= polarization[mask_inside]
+        return BHJM / MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(  # pragma: no cover
+        msg
+    )
diff --git a/src/magpylib/_src/fields/field_BH_cylinder.py b/src/magpylib/_src/fields/field_BH_cylinder.py
new file mode 100644
index 000000000..04fa1a52a
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_cylinder.py
@@ -0,0 +1,399 @@
+"""
+Implementations of analytical expressions for the magnetic field of
+homogeneously magnetized Cylinders. Computation details in function docstrings.
+"""
+
+# pylint: disable = no-name-in-module
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+from scipy.special import ellipe, ellipk
+
+from magpylib._src.fields.special_cel import cel
+from magpylib._src.input_checks import check_field_input
+from magpylib._src.utility import cart_to_cyl_coordinates, cyl_field_to_cart
+
+
+# CORE
+def magnet_cylinder_axial_Bfield(z0: np.ndarray, r: np.ndarray, z: np.ndarray) -> list:
+    """B-field of axially magnetized cylinders in Cylinder Coordinates.
+
+    The cylinder axes coincide with the z-axis of the Cylindrical CS and the
+    geometric center of the cylinder lies in the origin. Length inputs are
+    made dimensionless by division over the cylinder radii. Unit polarization
+    is assumed.
+
+    Parameters
+    ----------
+    z0: ndarray, shape (n,)
+        Ratios of half cylinder heights over cylinder radii.
+
+    r: ndarray, shape (n,)
+        Ratios of radial observer positions over cylinder radii.
+
+    z: Ratios of axial observer positions over cylinder radii.
+
+    Returns
+    -------
+    B-field: ndarray, (Br, Bphi, Bz)
+        B-field generated by Cylinders at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.magnet_cylinder_axial_Bfield(
+    ...    z0=np.array([1,2]),
+    ...    r =np.array([1,2]),
+    ...    z =np.array([2,3]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...    print(B)
+    [[0.056 0.041]
+     [0.    0.   ]
+     [0.067 0.018]]
+
+    Notes
+    -----
+    Implementation based on Derby, American Journal of Physics 78.3 (2010): 229-235.
+    """
+    n = len(z0)
+
+    # some important quantities
+    zph, zmh = z + z0, z - z0
+    dpr, dmr = 1 + r, 1 - r
+
+    sq0 = np.sqrt(zmh**2 + dpr**2)
+    sq1 = np.sqrt(zph**2 + dpr**2)
+
+    k1 = np.sqrt((zph**2 + dmr**2) / (zph**2 + dpr**2))
+    k0 = np.sqrt((zmh**2 + dmr**2) / (zmh**2 + dpr**2))
+    gamma = dmr / dpr
+    one = np.ones(n)
+
+    # radial field (unit polarization)
+    Br = (cel(k1, one, one, -one) / sq1 - cel(k0, one, one, -one) / sq0) / np.pi
+
+    # axial field (unit polarization)
+    Bz = (
+        1
+        / dpr
+        * (
+            zph * cel(k1, gamma**2, one, gamma) / sq1
+            - zmh * cel(k0, gamma**2, one, gamma) / sq0
+        )
+        / np.pi
+    )
+
+    return np.vstack((Br, np.zeros(n), Bz))
+
+
+# CORE
+def magnet_cylinder_diametral_Hfield(
+    z0: np.ndarray,
+    r: np.ndarray,
+    z: np.ndarray,
+    phi: np.ndarray,
+) -> list:
+    """B-field of diametrally magnetized cylinders in Cylinder Coordinates.
+
+    The cylinder axes coincide with the z-axis of the Cylindrical CS and the
+    geometric center of the cylinder lies in the origin. Length inputs are
+    made dimensionless by division over the cylinder radii. Unit magnetization
+    is assumed.
+
+    Parameters
+    ----------
+    z0: ndarray, shape (n,)
+        Ratios of cylinder heights over cylinder radii.
+
+    r: ndarray, shape (n,)
+        Ratios of radial observer positions over cylinder radii.
+
+    z: ndarray, shape (n,)
+        Ratios of axial observer positions over cylinder radii.
+
+    phi: ndarray, shape(n,), unit rad
+        Azimuth angles between observers and magnetization directions.
+
+    Returns
+    -------
+    H-Field: np.ndarray, (Hr, Hphi, Hz)
+        H-field generated by Cylinders at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.magnet_cylinder_diametral_Hfield(
+    ...    z0=np.array([1,2]),
+    ...    r =np.array([1,2]),
+    ...    z =np.array([2,3]),
+    ...    phi=np.array([.1,np.pi/4]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[-0.021  0.007]
+     [ 0.005  0.02 ]
+     [ 0.055  0.029]]
+
+    Notes
+    -----
+    Implementation partially based on Caciagli: Journal of Magnetism and
+    Magnetic Materials 456 (2018): 423-432, and [Ortner, Leitner, Rauber]
+    (unpublished).
+    """
+    # pylint: disable=too-many-statements
+
+    n = len(z0)
+
+    # allocate to treat small r special cases
+    Hr, Hphi, Hz = np.empty((3, n))
+
+    # compute repeated quantities for all cases
+    zp = z + z0
+    zm = z - z0
+
+    zp2 = zp**2
+    zm2 = zm**2
+    r2 = r**2
+
+    # case small_r: numerical instability of general solution
+    mask_small_r = r < 0.05
+    mask_general = ~mask_small_r
+    if np.any(mask_small_r):
+        phiX = phi[mask_small_r]
+        zpX, zmX = zp[mask_small_r], zm[mask_small_r]
+        zp2X, zm2X = zp2[mask_small_r], zm2[mask_small_r]
+        rX, r2X = r[mask_small_r], r2[mask_small_r]
+
+        # taylor series for small r
+        zpp = zp2X + 1
+        zmm = zm2X + 1
+        sqrt_p = np.sqrt(zpp)
+        sqrt_m = np.sqrt(zmm)
+
+        frac1 = zpX / sqrt_p
+        frac2 = zmX / sqrt_m
+
+        r3X = r2X * rX
+        r4X = r3X * rX
+        r5X = r4X * rX
+
+        term1 = frac1 - frac2
+        term2 = (frac1 / zpp**2 - frac2 / zmm**2) * r2X / 8
+        term3 = (
+            ((3 - 4 * zp2X) * frac1 / zpp**4 - (3 - 4 * zm2X) * frac2 / zmm**4)
+            / 64
+            * r4X
+        )
+
+        Hr[mask_small_r] = -np.cos(phiX) / 4 * (term1 + 9 * term2 + 25 * term3)
+
+        Hphi[mask_small_r] = np.sin(phiX) / 4 * (term1 + 3 * term2 + 5 * term3)
+
+        Hz[mask_small_r] = (
+            -np.cos(phiX)
+            / 4
+            * (
+                rX * (1 / zpp / sqrt_p - 1 / zmm / sqrt_m)
+                + 3
+                / 8
+                * r3X
+                * ((1 - 4 * zp2X) / zpp**3 / sqrt_p - (1 - 4 * zm2X) / zmm**3 / sqrt_m)
+                + 15
+                / 64
+                * r5X
+                * (
+                    (1 - 12 * zp2X + 8 * zp2X**2) / zpp**5 / sqrt_p
+                    - (1 - 12 * zm2X + 8 * zm2X**2) / zmm**5 / sqrt_m
+                )
+            )
+        )
+
+        # if there are small_r, select the general/case variables
+        # when there are no small_r cases it is not necessary to slice with [True, True, Tue,...]
+        phi = phi[mask_general]
+        n = len(phi)
+        zp, zm = zp[mask_general], zm[mask_general]
+        zp2, zm2 = zp2[mask_general], zm2[mask_general]
+        r, r2 = r[mask_general], r2[mask_general]
+
+    if np.any(mask_general):
+        rp = r + 1
+        rm = r - 1
+        rp2 = rp**2
+        rm2 = rm**2
+
+        ap2 = zp2 + rm**2
+        am2 = zm2 + rm**2
+        ap = np.sqrt(ap2)
+        am = np.sqrt(am2)
+
+        argp = -4 * r / ap2
+        argm = -4 * r / am2
+
+        # special case r=r0 : indefinite form
+        #   result is numerically stable in the vicinity of of r=r0
+        #   so only the special case must be caught (not the surroundings)
+        mask_special = rm == 0
+        argc = np.ones(n) * 1e16  # should be np.Inf but leads to 1/0 problems in cel
+        argc[~mask_special] = -4 * r[~mask_special] / rm2[~mask_special]
+        # special case 1/rm
+        one_over_rm = np.zeros(n)
+        one_over_rm[~mask_special] = 1 / rm[~mask_special]
+
+        elle_p = ellipe(argp)
+        elle_m = ellipe(argm)
+        ellk_p = ellipk(argp)
+        ellk_m = ellipk(argm)
+        onez = np.ones(n)
+        ellpi_p = cel(np.sqrt(1 - argp), 1 - argc, onez, onez)  # elliptic_Pi
+        ellpi_m = cel(np.sqrt(1 - argm), 1 - argc, onez, onez)  # elliptic_Pi
+
+        # compute fields
+        Hr[mask_general] = (
+            -np.cos(phi)
+            / (4 * np.pi * r2)
+            * (
+                -zm * am * elle_m
+                + zp * ap * elle_p
+                + zm / am * (2 + zm2) * ellk_m
+                - zp / ap * (2 + zp2) * ellk_p
+                + (zm / am * ellpi_m - zp / ap * ellpi_p) * rp * (r2 + 1) * one_over_rm
+            )
+        )
+
+        Hphi[mask_general] = (
+            np.sin(phi)
+            / (4 * np.pi * r2)
+            * (
+                +zm * am * elle_m
+                - zp * ap * elle_p
+                - zm / am * (2 + zm2 + 2 * r2) * ellk_m
+                + zp / ap * (2 + zp2 + 2 * r2) * ellk_p
+                + zm / am * rp2 * ellpi_m
+                - zp / ap * rp2 * ellpi_p
+            )
+        )
+
+        Hz[mask_general] = (
+            -np.cos(phi)
+            / (2 * np.pi * r)
+            * (
+                +am * elle_m
+                - ap * elle_p
+                - (1 + zm2 + r2) / am * ellk_m
+                + (1 + zp2 + r2) / ap * ellk_p
+            )
+        )
+
+    return np.vstack((Hr, Hphi, Hz))
+
+
+def BHJM_magnet_cylinder(
+    field: str,
+    observers: np.ndarray,
+    dimension: np.ndarray,
+    polarization: np.ndarray,
+) -> np.ndarray:
+    """
+    - Translate cylinder core fields to BHJM
+    - special cases
+    """
+
+    check_field_input(field)
+
+    # transform to Cy CS --------------------------------------------
+    r, phi, z = cart_to_cyl_coordinates(observers)
+    r0, z0 = dimension.T / 2
+
+    # scale invariance (make dimensionless)
+    r = r / r0
+    z = z / r0
+    z0 = z0 / r0
+
+    # allocate for output
+    BHJM = polarization.astype(float)
+
+    # inside/outside
+    mask_between_bases = np.abs(z) <= z0  # in-between top and bottom plane
+    mask_inside_hull = r <= 1  # inside Cylinder hull plane
+    mask_inside = mask_between_bases & mask_inside_hull
+
+    if field == "J":
+        BHJM[~mask_inside] = 0
+        return BHJM
+
+    if field == "M":
+        BHJM[~mask_inside] = 0
+        return BHJM / MU0
+
+    # SPECIAL CASE 1: on Cylinder edge
+    mask_on_hull = np.isclose(r, 1, rtol=1e-15, atol=0)  # on Cylinder hull plane
+    mask_on_bases = np.isclose(abs(z), z0, rtol=1e-15, atol=0)  # on top or bottom plane
+    mask_not_on_edge = ~(mask_on_hull & mask_on_bases)
+
+    # axial/transv polarization cases
+    pol_x, pol_y, pol_z = polarization.T
+    mask_pol_tv = (pol_x != 0) | (pol_y != 0)
+    mask_pol_ax = pol_z != 0
+
+    # SPECIAL CASE 2: pol = 0
+    mask_pol_not_null = ~((pol_x == 0) * (pol_y == 0) * (pol_z == 0))
+
+    # general case
+    mask_gen = mask_pol_not_null & mask_not_on_edge
+
+    # general case masks
+    mask_pol_tv = mask_pol_tv & mask_gen
+    mask_pol_ax = mask_pol_ax & mask_gen
+    mask_inside = mask_inside & mask_gen
+
+    BHJM *= 0
+
+    # transversal polarization contributions -----------------------
+    if any(mask_pol_tv):
+        pol_xy = np.sqrt(pol_x**2 + pol_y**2)[mask_pol_tv]
+        tetta = np.arctan2(pol_y[mask_pol_tv], pol_x[mask_pol_tv])
+
+        BHJM[mask_pol_tv] = (
+            magnet_cylinder_diametral_Hfield(
+                z0=z0[mask_pol_tv],
+                r=r[mask_pol_tv],
+                z=z[mask_pol_tv],
+                phi=phi[mask_pol_tv] - tetta,
+            )
+            * pol_xy
+        ).T
+
+    # axial polarization contributions ----------------------------
+    if any(mask_pol_ax):
+        BHJM[mask_pol_ax] += (
+            magnet_cylinder_axial_Bfield(
+                z0=z0[mask_pol_ax],
+                r=r[mask_pol_ax],
+                z=z[mask_pol_ax],
+            )
+            * pol_z[mask_pol_ax]
+        ).T
+
+    BHJM[:, 0], BHJM[:, 1] = cyl_field_to_cart(phi, BHJM[:, 0], BHJM[:, 1])
+
+    # add/subtract Mag when inside for B/H
+    if field == "B":
+        mask_tv_inside = mask_pol_tv * mask_inside
+        if any(mask_tv_inside):  # tv computes H-field
+            BHJM[mask_tv_inside, 0] += pol_x[mask_tv_inside]
+            BHJM[mask_tv_inside, 1] += pol_y[mask_tv_inside]
+        return BHJM
+
+    if field == "H":
+        mask_ax_inside = mask_pol_ax * mask_inside
+        if any(mask_ax_inside):  # ax computes B-field
+            BHJM[mask_ax_inside, 2] -= pol_z[mask_ax_inside]
+        return BHJM / MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_cylinder_segment.py b/src/magpylib/_src/fields/field_BH_cylinder_segment.py
new file mode 100644
index 000000000..2d0e775b7
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_cylinder_segment.py
@@ -0,0 +1,2469 @@
+# pylint: disable=too-many-lines
+# pylint: disable=line-too-long
+# pylint: disable=missing-function-docstring
+# pylint: disable=no-name-in-module
+# pylint: disable=too-many-statements
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+from scipy.special import ellipeinc, ellipkinc
+
+from magpylib._src.fields.field_BH_cylinder import BHJM_magnet_cylinder
+from magpylib._src.fields.special_el3 import el3_angle
+from magpylib._src.input_checks import check_field_input
+
+
+def arctan_k_tan_2(k, phi):
+    """
+    help function for periodic continuation
+
+    what is this function doing exactly ? what are the argument types, ranges, ...
+
+    can be replaced by non-masked version ?
+    """
+
+    full_periods = np.round(phi / (2.0 * np.pi))
+    phi_red = phi - full_periods * 2.0 * np.pi
+
+    result = full_periods * np.pi
+
+    return np.where(
+        np.abs(phi_red) < np.pi,
+        result + np.arctan(k * np.tan(phi_red / 2.0)),
+        result + phi_red / 2.0,
+    )
+
+
+def close(arg1: np.ndarray, arg2: np.ndarray) -> np.ndarray:
+    """
+    determine if arg1 and arg2 lie close to each other
+    input: ndarray, shape (n,) or numpy-interpretable scalar
+    output: ndarray, dtype=bool
+    """
+    return np.isclose(arg1, arg2, rtol=1e-12, atol=1e-12)
+
+
+def determine_cases(r, phi, z, r1, phi1, z1):
+    """
+    Determine case of input parameter set.
+        r, phi, z: observer positions
+        r1, phi1, z1: boundary values
+
+    All inputs must be ndarrays, shape (n,)
+
+    Returns: case numbers, ndarray, shape (n,), dtype=int
+
+    The case number is a three digits integer, where the digits can be the following values
+      1st digit: 1:z=z1,  2:general
+      2nd digit: 1:phi-phi1= 2n*pi,  2:phi-phi1=(2n+1)*pi,  3:general
+      3rd digit: 1:r=r1=0,  2:r=0,  3:r1=0,  4:r=r1>0,  5:general
+    """
+    n = len(r)  # input length
+
+    # allocate result
+    result = np.ones((3, n))
+
+    # identify z-case
+    mask_z = close(z, z1)
+    result[0] = 200
+    result[0, mask_z] = 100
+
+    # identify phi-case
+    mod_2pi = np.abs(phi - phi1) % (2 * np.pi)
+    mask_phi1 = np.logical_or(close(mod_2pi, 0), close(mod_2pi, 2 * np.pi))
+    mod_pi = np.abs(phi - phi1) % np.pi
+    mask_phi2 = np.logical_or(close(mod_pi, 0), close(mod_pi, np.pi))
+    result[1] = 30
+    result[1, mask_phi2] = 20
+    result[1, mask_phi1] = 10
+
+    # identify r-case
+    mask_r2 = close(r, 0)
+    mask_r3 = close(r1, 0)
+    mask_r4 = close(r, r1)
+    mask_r1 = mask_r2 * mask_r3
+    result[2] = 5
+    result[2, mask_r4] = 4
+    result[2, mask_r3] = 3
+    result[2, mask_r2] = 2
+    result[2, mask_r1] = 1
+
+    return np.array(np.sum(result, axis=0), dtype=int)
+
+
+# Implementation of all non-zero field components in every special case
+# e.g. Hphi_zk stands for field component in phi-direction originating
+# from the cylinder tile face at zk
+
+# 112 ##############
+
+
+def Hphi_zk_case112(r_i, theta_M):
+    return np.cos(theta_M) * np.log(r_i)
+
+
+def Hz_ri_case112(phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M)
+
+
+def Hz_phij_case112(r_i, phi_bar_M, theta_M):
+    return np.sin(theta_M) * np.sin(phi_bar_M) * np.log(r_i)
+
+
+# 113 ##############
+
+
+def Hphi_zk_case113(r, theta_M):
+    return -np.cos(theta_M) * np.log(r)
+
+
+def Hz_phij_case113(r, phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M) * np.log(r)
+
+
+# 115 ##############
+
+
+def Hr_zk_case115(r, r_i, r_bar_i, phi_bar_j, theta_M):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    E_coef = np.cos(theta_M) * np.abs(r_bar_i) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    F_coef = -np.cos(theta_M) * (r**2 + r_i**2) / (r * np.abs(r_bar_i))
+    return E_coef * E + F_coef * F
+
+
+def Hphi_zk_case115(r, r_i, r_bar_i, theta_M):
+    t1 = r_i / r
+    t1_coef = -np.cos(theta_M) * np.sign(r_bar_i)
+    t2 = np.log(np.abs(r_bar_i)) * np.sign(r_bar_i)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case115(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M):
+    t1 = np.abs(r_bar_i) / r
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.abs(r_bar_i) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    F_coef = (
+        -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (r * np.abs(r_bar_i))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case115(r_bar_i, phi_bar_M, theta_M):
+    t1 = np.log(np.abs(r_bar_i)) * np.sign(r_bar_i)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+# 122 ##############
+
+
+def Hphi_zk_case122(r_i, theta_M):
+    return -np.cos(theta_M) * np.log(r_i)
+
+
+def Hz_ri_case122(phi_bar_M, theta_M):
+    return np.sin(theta_M) * np.sin(phi_bar_M)
+
+
+def Hz_phij_case122(r_i, phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M) * np.log(r_i)
+
+
+# 123 ##############
+
+
+def Hphi_zk_case123(r, theta_M):
+    return -np.cos(theta_M) * np.log(r)
+
+
+def Hz_phij_case123(r, phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M) * np.log(r)
+
+
+# 124 ##############
+
+
+def Hphi_zk_case124(r, theta_M):
+    return np.cos(theta_M) * (1.0 - np.log(2.0 * r))
+
+
+def Hz_ri_case124(phi_bar_M, theta_M):
+    return 2.0 * np.sin(theta_M) * np.sin(phi_bar_M)
+
+
+def Hz_phij_case124(r, phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M) * np.log(2.0 * r)
+
+
+# 125 ##############
+
+
+def Hr_zk_case125(r, r_i, r_bar_i, phi_bar_j, theta_M):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    E_coef = np.cos(theta_M) * np.abs(r_bar_i) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    F_coef = -np.cos(theta_M) * (r**2 + r_i**2) / (r * np.abs(r_bar_i))
+    return E_coef * E + F_coef * F
+
+
+def Hphi_zk_case125(r, r_i, theta_M):
+    return np.cos(theta_M) / r * (r_i - r * np.log(r + r_i))
+
+
+def Hz_ri_case125(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.abs(r_bar_i) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    F_coef = (
+        -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (r * np.abs(r_bar_i))
+    )
+    return np.sin(theta_M) * np.sin(phi_bar_M) * (r + r_i) / r + E_coef * E + F_coef * F
+
+
+def Hz_phij_case125(r, r_i, phi_bar_M, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_M) * np.log(r + r_i)
+
+
+# 132 ##############
+
+
+def Hr_zk_case132(r_i, phi_bar_j, theta_M):
+    return np.cos(theta_M) * np.sin(phi_bar_j) * np.log(r_i)
+
+
+def Hphi_zk_case132(r_i, phi_bar_j, theta_M):
+    return np.cos(theta_M) * np.cos(phi_bar_j) * np.log(r_i)
+
+
+def Hz_ri_case132(phi_bar_Mj, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_Mj)
+
+
+def Hz_phij_case132(r_i, phi_bar_Mj, theta_M):
+    return np.sin(theta_M) * np.sin(phi_bar_Mj) * np.log(r_i)
+
+
+# 133 ##############
+
+
+def Hr_zk_case133(r, phi_bar_j, theta_M):
+    return -np.cos(theta_M) * np.sin(phi_bar_j) + np.cos(theta_M) * np.sin(
+        phi_bar_j
+    ) * np.log(r * (1.0 - np.cos(phi_bar_j)))
+
+
+def Hphi_zk_case133(phi_bar_j, theta_M):
+    return np.cos(theta_M) - np.cos(theta_M) * np.cos(phi_bar_j) * np.arctanh(
+        np.cos(phi_bar_j)
+    )
+
+
+def Hz_phij_case133(phi_bar_j, phi_bar_Mj, theta_M):
+    return -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.arctanh(np.cos(phi_bar_j))
+
+
+# 134 ##############
+
+
+def Hr_zk_case134(r, phi_bar_j, theta_M):
+    t1 = np.sin(phi_bar_j)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.sin(phi_bar_j) / np.sqrt(1.0 - np.cos(phi_bar_j))
+    t2_coef = -np.sqrt(2.0) * np.cos(theta_M)
+    t3 = np.log(
+        r * (1.0 - np.cos(phi_bar_j) + np.sqrt(2.0) * np.sqrt(1.0 - np.cos(phi_bar_j)))
+    )
+    t3_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    t4 = np.arctanh(
+        np.sin(phi_bar_j) / (np.sqrt(2.0) * np.sqrt(1.0 - np.cos(phi_bar_j)))
+    )
+    t4_coef = np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2 + t3_coef * t3 + t4_coef * t4
+
+
+def Hphi_zk_case134(phi_bar_j, theta_M):
+    return np.sqrt(2) * np.cos(theta_M) * np.sqrt(1 - np.cos(phi_bar_j)) + np.cos(
+        theta_M
+    ) * np.cos(phi_bar_j) * np.arctanh(np.sqrt((1 - np.cos(phi_bar_j)) / 2))
+
+
+def Hz_ri_case134(phi_bar_j, phi_bar_M, theta_M):
+    t1 = np.sqrt(1.0 - np.cos(phi_bar_j))
+    t1_coef = np.sqrt(2.0) * np.sin(theta_M) * np.sin(phi_bar_M)
+    t2 = np.sin(phi_bar_j) / t1
+    t2_coef = -np.sqrt(2.0) * np.sin(theta_M) * np.cos(phi_bar_M)
+    t3 = np.arctanh(t2 / np.sqrt(2.0))
+    t3_coef = np.sin(theta_M) * np.cos(phi_bar_M)
+    return t1_coef * t1 + t2_coef * t2 + t3_coef * t3
+
+
+def Hz_phij_case134(phi_bar_j, phi_bar_Mj, theta_M):
+    return (
+        np.sin(theta_M)
+        * np.sin(phi_bar_Mj)
+        * np.arctanh(np.sqrt((1.0 - np.cos(phi_bar_j)) / 2.0))
+    )
+
+
+# 135 ##############
+
+
+def Hr_zk_case135(r, r_i, r_bar_i, phi_bar_j, theta_M):
+    t1 = np.sin(phi_bar_j)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.log(
+        r_i
+        - r * np.cos(phi_bar_j)
+        + np.sqrt(r_i**2 + r**2 - 2 * r_i * r * np.cos(phi_bar_j))
+    )
+    t2_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    E_coef = np.cos(theta_M) * np.abs(r_bar_i) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / r_bar_i**2)
+    F_coef = -np.cos(theta_M) * (r**2 + r_i**2) / (r * np.abs(r_bar_i))
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F
+
+
+def Hphi_zk_case135(r, r_i, phi_bar_j, theta_M):
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j))
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh((r * np.cos(phi_bar_j) - r_i) / t1)
+    t2_coef = -np.cos(theta_M) * np.cos(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case135(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M):
+    t = r_bar_i**2
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j)) / r
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.sqrt(t) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    F_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (r * np.sqrt(t))
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case135(r, r_i, phi_bar_j, phi_bar_Mj, theta_M):
+    t1 = np.arctanh(
+        (r * np.cos(phi_bar_j) - r_i)
+        / np.sqrt(r**2 + r_i**2 - 2 * r * r_i * np.cos(phi_bar_j))
+    )
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+# 211 ##############
+
+
+def Hr_phij_case211(phi_bar_M, theta_M, z_bar_k):
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * np.log(np.abs(z_bar_k))
+    )
+
+
+def Hz_zk_case211(phi_j, theta_M, z_bar_k):
+    return -np.cos(theta_M) * np.sign(z_bar_k) * phi_j
+
+
+# 212 ##############
+
+
+def Hr_ri_case212(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 2.0 * phi_j * np.cos(phi_bar_M)
+    t3 = 1.0 / 4.0 * np.sin(phi_bar_M)
+    return t1 * (t2 - t3)
+
+
+def Hr_phij_case212(r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hphi_ri_case212(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 4.0 * np.cos(phi_bar_M)
+    t3 = 1.0 / 2.0 * phi_j * np.sin(phi_bar_M)
+    return t1 * (-t2 + t3)
+
+
+def Hphi_zk_case212(r_i, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.arctanh(t1)
+    t2_coef = np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case212(r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_phij_case212(r_i, phi_bar_M, theta_M, z_bar_k):
+    return (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.arctanh(r_i / np.sqrt(r_i**2 + z_bar_k**2))
+    )
+
+
+def Hz_zk_case212(r_i, phi_j, theta_M, z_bar_k):
+    t1 = phi_j / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M) * z_bar_k
+    return t1_coef * t1
+
+
+# 213 ##############
+
+
+def Hr_phij_case213(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hphi_zk_case213(r, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(r / t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_phij_case213(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(r / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case213(phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sign(z_bar_k)
+    t1_coef = np.cos(theta_M) * phi_bar_j
+    return t1_coef * t1
+
+
+# 214 ##############
+
+
+def Hr_ri_case214(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * z_bar_k**2
+        / (2.0 * r**2)
+        + E_coef * E
+        + F_coef * F
+    )
+
+
+def Hr_phij_case214(phi_bar_M, theta_M, z_bar_k):
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * np.log(np.abs(z_bar_k))
+    )
+
+
+def Hr_zk_case214(r, phi_bar_j, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.cos(theta_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = -np.cos(theta_M) * (2.0 * r**2 + z_bar_k**2) / (r * np.abs(z_bar_k))
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(phi_bar_j / 2, 2 * r / (r + sign * t), -4 * r**2 / z_bar_k**2)
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * z_bar_k**2))
+            * (t - sign * r)
+            * (r + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            1.0 - z_bar_k**4 / ((4.0 * r**2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4.0 * r**2 / (4.0 * r**2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**4
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * (4.0 * r**2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case214(r, phi_j, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = -np.sin(theta_M) * np.cos(phi_bar_M) * np.sign(z_bar_k) / 2.0
+    t2 = phi_j
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_M) * np.sign(z_bar_k) / 2.0
+    t3 = np.sign(z_bar_k) * z_bar_k**2 / (2.0 * r**2)
+    t3_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    t4 = np.log(np.abs(z_bar_k) / (np.sqrt(2.0) * r))
+    t4_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * np.sign(z_bar_k)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (4.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return t1 + t2_coef * t2 + t3_coef * t3 + t4_coef * t4 + E_coef * E + F_coef * F
+
+
+def Hphi_zk_case214(r, theta_M, z_bar_k):
+    t1 = np.abs(z_bar_k)
+    t1_coef = np.cos(theta_M) / r
+    return t1_coef * t1
+
+
+def Hz_ri_case214(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (r * np.abs(z_bar_k))
+    )
+    return (
+        np.sin(theta_M) * np.sin(phi_bar_M) * np.abs(z_bar_k) / r
+        + E_coef * E
+        + F_coef * F
+    )
+
+
+def Hz_zk_case214(r, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(phi_bar_j / 2, 2 * r / (r + sign * t), -4 * r**2 / z_bar_k**2)
+
+    Pi_coef = np.cos(theta_M) * np.sign(z_bar_k)
+    return Pi_coef * Pi(1) + Pi_coef * Pi(-1)
+
+
+# 215 ##############
+
+
+def Hr_ri_case215(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t2 = np.arctanh(z_bar_k / np.sqrt(r_bar_i**2 + z_bar_k**2))
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_M) / 2.0 * (1.0 - r_i**2 / r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r_i**2 + z_bar_k**2)
+        / (2 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (r**2 + r_i**2)
+        * (r + r_i)
+        / (2.0 * r**2 * r_bar_i * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2.0 * r**2)
+        + t2_coef * t2
+        + E_coef * E
+        + F_coef * F
+        + Pi_coef * Pi
+    )
+
+
+def Hr_phij_case215(r_bar_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r_bar_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hr_zk_case215(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = np.cos(theta_M) * np.sqrt(r_bar_i**2 + z_bar_k**2) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.cos(theta_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * (r_bar_i**2 + z_bar_k**2)))
+            * (t - sign * r)
+            * (r_i + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            1.0
+            - z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (((r + r_i) ** 2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4 * r * r_i / ((r + r_i) ** 2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * ((r + r_i) ** 2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case215(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(r_bar_i**2 + z_bar_k**2) * z_bar_k / (2.0 * r**2)
+    t1_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    t2 = np.arctanh(z_bar_k / np.sqrt(r_bar_i**2 + z_bar_k**2))
+    t2_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (2.0 * r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r**2 + 2.0 * r_i**2 + z_bar_k**2)
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (r + r_i) ** 2
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F + Pi_coef * Pi
+
+
+def Hphi_zk_case215(r, r_bar_i, theta_M, z_bar_k):
+    t1 = np.sqrt(r_bar_i**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(r_bar_i / t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case215(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t = r_bar_i**2 + z_bar_k**2
+    t1 = np.sqrt(r_bar_i**2 + z_bar_k**2) / r
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.sqrt(t) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(t))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case215(r_bar_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(r_bar_i / np.sqrt(r_bar_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case215(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi_coef(sign):
+        return (
+            np.cos(theta_M)
+            * z_bar_k
+            * (r_i + sign * t)
+            / (np.sqrt(r_bar_i**2 + z_bar_k**2) * (r + sign * t))
+        )
+
+    return Pi_coef(1) * Pi(1) + Pi_coef(-1) * Pi(-1)
+
+
+# 221 ##############
+
+
+def Hr_phij_case221(phi_bar_M, theta_M, z_bar_k):
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * np.log(np.abs(z_bar_k))
+    )
+
+
+def Hz_zk_case221(phi_j, theta_M, z_bar_k):
+    return -np.cos(theta_M) * np.sign(z_bar_k) * phi_j
+
+
+# 222 ##############
+
+
+def Hr_ri_case222(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 2.0 * phi_j * np.cos(phi_bar_M)
+    t3 = 1.0 / 4.0 * np.sin(phi_bar_M)
+    return t1 * (t2 - t3)
+
+
+def Hr_phij_case222(r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hphi_ri_case222(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 4.0 * np.cos(phi_bar_M)
+    t3 = 1.0 / 2.0 * phi_j * np.sin(phi_bar_M)
+    return t1 * (-t2 + t3)
+
+
+def Hphi_zk_case222(r_i, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M)
+    t2 = np.arctanh(t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case222(r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_phij_case222(r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(r_i / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case222(r_i, phi_j, theta_M, z_bar_k):
+    t1 = z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M) * phi_j
+    return t1_coef * t1
+
+
+# 223 ##############
+
+
+def Hr_phij_case223(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hphi_zk_case223(r, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(r / t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_phij_case223(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(r / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case223(r, phi_bar_j, theta_M, z_bar_k):
+    t1 = arctan_k_tan_2(np.sqrt(r**2 + z_bar_k**2) / np.abs(z_bar_k), 2.0 * phi_bar_j)
+    t1_coef = np.cos(theta_M) * np.sign(z_bar_k)
+    return t1_coef * t1
+
+
+# 224 ##############
+
+
+def Hr_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(4.0 * r**2 + z_bar_k**2) * z_bar_k / (2.0 * r**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hr_phij_case224(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(4.0 * r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hr_zk_case224(r, phi_bar_j, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.cos(theta_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = -np.cos(theta_M) * (2.0 * r**2 + z_bar_k**2) / (r * np.abs(z_bar_k))
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(
+            phi_bar_j / 2.0, 2.0 * r / (r + sign * t), -4.0 * r**2 / z_bar_k**2
+        )
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * z_bar_k**2))
+            * (t - sign * r)
+            * (r + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            1.0 - z_bar_k**4 / ((4.0 * r**2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4.0 * r**2 / (4.0 * r**2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**4
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * (4.0 * r**2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(4.0 * r**2 + z_bar_k**2) * z_bar_k / (2.0 * r**2)
+    t1_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    t2 = np.arctanh(z_bar_k / np.sqrt(4.0 * r**2 + z_bar_k**2))
+    t2_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (4.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F
+
+
+def Hphi_zk_case224(r, theta_M, z_bar_k):
+    t1 = np.sqrt(4.0 * r**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(2.0 * r / t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(4.0 * r**2 + z_bar_k**2) / r
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (r * np.abs(z_bar_k))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case224(r, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(2.0 * r / np.sqrt(4.0 * r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case224(r, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(
+            phi_bar_j / 2.0, 2.0 * r / (r + sign * t), -4.0 * r**2 / z_bar_k**2
+        )
+
+    Pi_coef = np.cos(theta_M) * np.sign(z_bar_k)
+    return Pi_coef * Pi(1) + Pi_coef * Pi(-1)
+
+
+# 225 ##############
+
+
+def Hr_ri_case225(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt((r + r_i) ** 2 + z_bar_k**2) * z_bar_k / (2.0 * r**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    t2 = np.arctanh(z_bar_k / np.sqrt((r + r_i) ** 2 + z_bar_k**2))
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_M) / 2.0 * (1.0 - r_i**2 / r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r_i**2 + z_bar_k**2)
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (r**2 + r_i**2)
+        * (r + r_i)
+        / (2.0 * r**2 * r_bar_i * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F + Pi_coef * Pi
+
+
+def Hr_phij_case225(r, r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt((r + r_i) ** 2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hr_zk_case225(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = np.cos(theta_M) * np.sqrt(r_bar_i**2 + z_bar_k**2) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.cos(theta_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * (r_bar_i**2 + z_bar_k**2)))
+            * (t - sign * r)
+            * (r_i + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            1.0
+            - z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (((r + r_i) ** 2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4.0 * r * r_i / ((r + r_i) ** 2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * ((r + r_i) ** 2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case225(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt((r + r_i) ** 2 + z_bar_k**2) * z_bar_k / (2.0 * r**2)
+    t1_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    t2 = np.arctanh(z_bar_k / np.sqrt((r + r_i) ** 2 + z_bar_k**2))
+    t2_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (2.0 * r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r**2 + 2.0 * r_i**2 + z_bar_k**2)
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (r + r_i) ** 2
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F + Pi_coef * Pi
+
+
+def Hphi_zk_case225(r, r_i, theta_M, z_bar_k):
+    t1 = np.sqrt((r + r_i) ** 2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh((r + r_i) / t1)
+    t2_coef = -np.cos(theta_M)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case225(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t = r_bar_i**2 + z_bar_k**2
+    t1 = np.sqrt((r + r_i) ** 2 + z_bar_k**2) / r
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.sqrt(t) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(t))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case225(r, r_i, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.arctanh((r + r_i) / np.sqrt((r + r_i) ** 2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M)
+    return t1_coef * t1
+
+
+def Hz_zk_case225(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi_coef(sign):
+        return (
+            np.cos(theta_M)
+            * z_bar_k
+            * (r_i + sign * t)
+            / (np.sqrt(r_bar_i**2 + z_bar_k**2) * (r + sign * t))
+        )
+
+    return Pi_coef(1) * Pi(1) + Pi_coef(-1) * Pi(-1)
+
+
+# 231 ##############
+
+
+def Hr_phij_case231(phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    return (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_Mj)
+        * np.cos(phi_bar_j)
+        * np.sign(z_bar_k)
+        * np.log(np.abs(z_bar_k))
+    )
+
+
+def Hphi_phij_case231(phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.log(np.abs(z_bar_k))
+    t1_coef = (
+        np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j) * np.sign(z_bar_k)
+    )
+    return t1_coef * t1
+
+
+def Hz_zk_case231(phi_j, theta_M, z_bar_k):
+    t1 = phi_j * np.sign(z_bar_k)
+    t1_coef = -np.cos(theta_M)
+    return t1_coef * t1
+
+
+# 232 ##############
+
+
+def Hr_ri_case232(r_i, phi_j, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 2.0 * phi_j * np.cos(phi_bar_M)
+    t3 = 1.0 / 4.0 * np.sin(phi_bar_Mj + phi_bar_j)
+    return t1 * (t2 - t3)
+
+
+def Hr_phij_case232(r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    return t1_coef * t1
+
+
+def Hr_zk_case232(r_i, phi_bar_j, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M) * np.sin(phi_bar_j)
+    t2 = np.arctanh(t1)
+    t2_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hphi_ri_case232(r_i, phi_j, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.sin(theta_M) * z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t2 = 1.0 / 4.0 * np.cos(phi_bar_Mj + phi_bar_j)
+    t3 = 1.0 / 2.0 * phi_j * np.sin(phi_bar_M)
+    return t1 * (-t2 + t3)
+
+
+def Hphi_phij_case232(r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1
+
+
+def Hphi_zk_case232(r_i, phi_bar_j, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M) * np.cos(phi_bar_j)
+    t2 = np.arctanh(t1)
+    t2_coef = np.cos(theta_M) * np.cos(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case232(r_i, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = r_i / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+def Hz_phij_case232(r_i, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(r_i / np.sqrt(r_i**2 + z_bar_k**2))
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+def Hz_zk_case232(r_i, phi_j, theta_M, z_bar_k):
+    t1 = z_bar_k / np.sqrt(r_i**2 + z_bar_k**2)
+    t1_coef = -np.cos(theta_M) * phi_j
+    return t1_coef * t1
+
+
+# 233 ##############
+
+
+def Hr_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(z_bar_k / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctan(
+        z_bar_k * np.cos(phi_bar_j) / np.sin(phi_bar_j) / np.sqrt(r**2 + z_bar_k**2)
+    )
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hr_zk_case233(r, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+    t1 = np.sin(phi_bar_j)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.log(-r * np.cos(phi_bar_j) + t)
+    t2_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    t3 = np.arctan(r * np.sin(phi_bar_j) / z_bar_k)
+    t3_coef = np.cos(theta_M) * z_bar_k / r
+    t4 = arctan_k_tan_2(t / np.abs(z_bar_k), 2.0 * phi_bar_j)
+    t4_coef = -t3_coef
+
+    def t5(sign):
+        return arctan_k_tan_2(np.abs(z_bar_k) / np.abs(r + sign * t), phi_bar_j)
+
+    t5_coef = t3_coef
+    return (
+        t1_coef * t1
+        + t2_coef * t2
+        + t3_coef * t3
+        + t4_coef * t4
+        + t5_coef * t5(1)
+        + t5_coef * t5(-1)
+    )
+
+
+def Hphi_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+    t1 = np.arctan(z_bar_k * np.cos(phi_bar_j) / (np.sin(phi_bar_j) * t))
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctanh(z_bar_k / t)
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hphi_zk_case233(r, phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(r * np.cos(phi_bar_j) / t1)
+    t2_coef = -np.cos(theta_M) * np.cos(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(r * np.cos(phi_bar_j) / np.sqrt(r**2 + z_bar_k**2))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+def Hz_zk_case233(r, phi_bar_j, theta_M, z_bar_k):
+    t1 = arctan_k_tan_2(np.sqrt(r**2 + z_bar_k**2) / np.abs(z_bar_k), 2.0 * phi_bar_j)
+    t1_coef = np.cos(theta_M) * np.sign(z_bar_k)
+    return t1_coef * t1
+
+
+# 234 ##############
+
+
+def Hr_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M) * z_bar_k / (2.0 * r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hr_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(
+        z_bar_k / np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    )
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctan(
+        z_bar_k
+        * (1.0 - np.cos(phi_bar_j))
+        / (
+            np.sin(phi_bar_j)
+            * np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+        )
+    )
+    t2_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hr_zk_case234(r, phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sin(phi_bar_j)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.log(
+        r * (1.0 - np.cos(phi_bar_j))
+        + np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    )
+    t2_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    t3 = np.arctan(r * np.sin(phi_bar_j) / z_bar_k)
+    t3_coef = np.cos(theta_M) * z_bar_k / r
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.cos(theta_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = -np.cos(theta_M) * (2.0 * r**2 + z_bar_k**2) / (r * np.abs(z_bar_k))
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(
+            phi_bar_j / 2.0, 2.0 * r / (r + sign * t), -4.0 * r**2 / z_bar_k**2
+        )
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * z_bar_k**2))
+            * (t - sign * r)
+            * (r + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            arctan_k_tan_2(np.sqrt((4.0 * r**2 + z_bar_k**2) / z_bar_k**2), phi_bar_j),
+            1.0 - z_bar_k**4 / ((4.0 * r**2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4.0 * r**2 / (4.0 * r**2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**4
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * (4.0 * r**2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        t1_coef * t1
+        + t2_coef * t2
+        + t3_coef * t3
+        + E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * z_bar_k / (2.0 * r**2)
+    t2 = np.arctanh(
+        z_bar_k / np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    )
+    t2_coef = -np.sin(theta_M) * np.cos(phi_bar_M)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k**2
+        * np.sign(z_bar_k)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * np.sign(z_bar_k)
+        * (4.0 * r**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F
+
+
+def Hphi_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t = np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    t1 = np.arctan(z_bar_k * (1.0 - np.cos(phi_bar_j)) / (np.sin(phi_bar_j) * t))
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctanh(z_bar_k / t)
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hphi_zk_case234(r, phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh(r * (1.0 - np.cos(phi_bar_j)) / t1)
+    t2_coef = np.cos(theta_M) * np.cos(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M) / r
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.abs(z_bar_k) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r**2 / z_bar_k**2)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (2.0 * r**2 + z_bar_k**2)
+        / (r * np.abs(z_bar_k))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(
+        r
+        * (1.0 - np.cos(phi_bar_j))
+        / np.sqrt(2.0 * r**2 * (1.0 - np.cos(phi_bar_j)) + z_bar_k**2)
+    )
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+def Hz_zk_case234(r, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(
+            phi_bar_j / 2.0, 2.0 * r / (r + sign * t), -4.0 * r**2 / z_bar_k**2
+        )
+
+    Pi_coef = np.cos(theta_M) * np.sign(z_bar_k)
+    return Pi_coef * Pi(1) + Pi_coef * Pi(-1)
+
+
+# 235 ##############
+
+
+def Hr_ri_case235(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_M) * z_bar_k / (2.0 * r**2)
+    t2 = np.arctanh(
+        z_bar_k
+        / np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    )
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_M) / 2.0 * (1.0 - r_i**2 / r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r_i**2 + z_bar_k**2)
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * z_bar_k
+        * (r**2 + r_i**2)
+        * (r + r_i)
+        / (2.0 * r**2 * r_bar_i * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F + Pi_coef * Pi
+
+
+def Hr_phij_case235(r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(
+        z_bar_k
+        / np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    )
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctan(
+        z_bar_k
+        * (r * np.cos(phi_bar_j) - r_i)
+        / (
+            r
+            * np.sin(phi_bar_j)
+            * np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+        )
+    )
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hr_zk_case235(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sin(phi_bar_j)
+    t1_coef = -np.cos(theta_M)
+    t2 = np.log(
+        r_i
+        - r * np.cos(phi_bar_j)
+        + np.sqrt(r_i**2 + r**2 - 2.0 * r_i * r * np.cos(phi_bar_j) + z_bar_k**2)
+    )
+    t2_coef = np.cos(theta_M) * np.sin(phi_bar_j)
+    t3 = np.arctan(r * np.sin(phi_bar_j) / z_bar_k)
+    t3_coef = np.cos(theta_M) * z_bar_k / r
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = np.cos(theta_M) * np.sqrt(r_bar_i**2 + z_bar_k**2) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.cos(theta_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi1(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi1_coef(sign):
+        return (
+            -np.cos(theta_M)
+            / (r * np.sqrt((r**2 + z_bar_k**2) * (r_bar_i**2 + z_bar_k**2)))
+            * (t - sign * r)
+            * (r_i + sign * t) ** 2
+        )
+
+    def Pi2(sign):
+        return el3_angle(
+            arctan_k_tan_2(
+                np.sqrt(((r_i + r) ** 2 + z_bar_k**2) / (r_bar_i**2 + z_bar_k**2)),
+                phi_bar_j,
+            ),
+            1.0
+            - z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (((r + r_i) ** 2 + z_bar_k**2) * (r + sign * t) ** 2),
+            4.0 * r * r_i / ((r + r_i) ** 2 + z_bar_k**2),
+        )
+
+    def Pi2_coef(sign):
+        return (
+            sign
+            * np.cos(theta_M)
+            * z_bar_k**2
+            * (r_bar_i**2 + z_bar_k**2)
+            / (
+                r
+                * np.sqrt((r**2 + z_bar_k**2) * ((r + r_i) ** 2 + z_bar_k**2))
+                * (r + sign * t)
+            )
+        )
+
+    return (
+        t1_coef * t1
+        + t2_coef * t2
+        + t3_coef * t3
+        + E_coef * E
+        + F_coef * F
+        + Pi1_coef(1) * Pi1(1)
+        + Pi1_coef(-1) * Pi1(-1)
+        + Pi2_coef(1) * Pi2(1)
+        + Pi2_coef(-1) * Pi2(-1)
+    )
+
+
+def Hphi_ri_case235(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    t1_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * z_bar_k / (2.0 * r**2)
+    t2 = np.arctanh(
+        z_bar_k
+        / np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    )
+    t2_coef = -np.sin(theta_M) * np.cos(phi_bar_M) * (r**2 + r_i**2) / (2.0 * r**2)
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    E_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * np.sqrt(r_bar_i**2 + z_bar_k**2)
+        / (2.0 * r**2)
+    )
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2))
+    F_coef = (
+        -np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (2.0 * r**2 + 2.0 * r_i**2 + z_bar_k**2)
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    Pi = el3_angle(
+        phi_bar_j / 2.0,
+        -4.0 * r * r_i / r_bar_i**2,
+        -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+    )
+    Pi_coef = (
+        np.sin(theta_M)
+        * np.sin(phi_bar_M)
+        * z_bar_k
+        * (r + r_i) ** 2
+        / (2.0 * r**2 * np.sqrt(r_bar_i**2 + z_bar_k**2))
+    )
+    return t1_coef * t1 + t2_coef * t2 + E_coef * E + F_coef * F + Pi_coef * Pi
+
+
+def Hphi_phij_case235(r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    t1 = np.arctan(
+        z_bar_k * (r * np.cos(phi_bar_j) - r_i) / (r * np.sin(phi_bar_j) * t)
+    )
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.cos(phi_bar_j)
+    t2 = np.arctanh(z_bar_k / t)
+    t2_coef = np.sin(theta_M) * np.sin(phi_bar_Mj) * np.sin(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hphi_zk_case235(r, r_i, phi_bar_j, theta_M, z_bar_k):
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    t1_coef = np.cos(theta_M) / r
+    t2 = np.arctanh((r * np.cos(phi_bar_j) - r_i) / t1)
+    t2_coef = -np.cos(theta_M) * np.cos(phi_bar_j)
+    return t1_coef * t1 + t2_coef * t2
+
+
+def Hz_ri_case235(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    t = r_bar_i**2 + z_bar_k**2
+    t1 = np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    t1_coef = np.sin(theta_M) * np.sin(phi_bar_M) / r
+    E = ellipeinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    E_coef = np.sin(theta_M) * np.cos(phi_bar_M) * np.sqrt(t) / r
+    F = ellipkinc(phi_bar_j / 2.0, -4.0 * r * r_i / t)
+    F_coef = (
+        -np.sin(theta_M)
+        * np.cos(phi_bar_M)
+        * (r**2 + r_i**2 + z_bar_k**2)
+        / (r * np.sqrt(t))
+    )
+    return t1_coef * t1 + E_coef * E + F_coef * F
+
+
+def Hz_phij_case235(r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    t1 = np.arctanh(
+        (r * np.cos(phi_bar_j) - r_i)
+        / np.sqrt(r**2 + r_i**2 - 2.0 * r * r_i * np.cos(phi_bar_j) + z_bar_k**2)
+    )
+    t1_coef = -np.sin(theta_M) * np.sin(phi_bar_Mj)
+    return t1_coef * t1
+
+
+def Hz_zk_case235(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k):
+    t = np.sqrt(r**2 + z_bar_k**2)
+
+    def Pi(sign):
+        return el3_angle(
+            phi_bar_j / 2.0,
+            2.0 * r / (r + sign * t),
+            -4.0 * r * r_i / (r_bar_i**2 + z_bar_k**2),
+        )
+
+    def Pi_coef(sign):
+        return (
+            np.cos(theta_M)
+            * z_bar_k
+            * (r_i + sign * t)
+            / (np.sqrt(r_bar_i**2 + z_bar_k**2) * (r + sign * t))
+        )
+
+    return Pi_coef(1) * Pi(1) + Pi_coef(-1) * Pi(-1)
+
+
+####################
+####################
+####################
+# calculation of all field components for each case
+# especially these function show, which inputs are needed for the calculation
+# full vectorization for all cases could be implemented here
+# input: ndarray, shape (n,)
+# out: ndarray, shape (n,3,3) # (n)vector, (3)r_phi_z, (3)face
+
+
+def case112(r_i, phi_bar_M, theta_M):
+    results = np.zeros((len(r_i), 3, 3))
+    results[:, 1, 2] = Hphi_zk_case112(r_i, theta_M)
+    results[:, 2, 0] = Hz_ri_case112(phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case112(r_i, phi_bar_M, theta_M)
+    return results
+
+
+def case113(r, phi_bar_M, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 1, 2] = Hphi_zk_case113(r, theta_M)
+    results[:, 2, 1] = Hz_phij_case113(r, phi_bar_M, theta_M)
+    return results
+
+
+def case115(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case115(r, r_i, r_bar_i, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case115(r, r_i, r_bar_i, theta_M)
+    results[:, 2, 0] = Hz_ri_case115(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case115(r_bar_i, phi_bar_M, theta_M)
+    return results
+
+
+def case122(r_i, phi_bar_M, theta_M):
+    results = np.zeros((len(r_i), 3, 3))
+    results[:, 1, 2] = Hphi_zk_case122(r_i, theta_M)
+    results[:, 2, 0] = Hz_ri_case122(phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case122(r_i, phi_bar_M, theta_M)
+    return results
+
+
+def case123(r, phi_bar_M, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 1, 2] = Hphi_zk_case123(r, theta_M)
+    results[:, 2, 1] = Hz_phij_case123(r, phi_bar_M, theta_M)
+    return results
+
+
+def case124(r, phi_bar_M, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 1, 2] = Hphi_zk_case124(r, theta_M)
+    results[:, 2, 0] = Hz_ri_case124(phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case124(r, phi_bar_M, theta_M)
+    return results
+
+
+def case125(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case125(r, r_i, r_bar_i, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case125(r, r_i, theta_M)
+    results[:, 2, 0] = Hz_ri_case125(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case125(r, r_i, phi_bar_M, theta_M)
+    return results
+
+
+def case132(r, r_i, phi_bar_j, phi_bar_Mj, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case132(r_i, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case132(r_i, phi_bar_j, theta_M)
+    results[:, 2, 0] = Hz_ri_case132(phi_bar_Mj, theta_M)
+    results[:, 2, 1] = Hz_phij_case132(r_i, phi_bar_Mj, theta_M)
+    return results
+
+
+def case133(r, phi_bar_j, phi_bar_Mj, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case133(r, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case133(phi_bar_j, theta_M)
+    results[:, 2, 1] = Hz_phij_case133(phi_bar_j, phi_bar_Mj, theta_M)
+    return results
+
+
+def case134(r, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case134(r, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case134(phi_bar_j, theta_M)
+    results[:, 2, 0] = Hz_ri_case134(phi_bar_j, phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case134(phi_bar_j, phi_bar_Mj, theta_M)
+    return results
+
+
+def case135(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 2] = Hr_zk_case135(r, r_i, r_bar_i, phi_bar_j, theta_M)
+    results[:, 1, 2] = Hphi_zk_case135(r, r_i, phi_bar_j, theta_M)
+    results[:, 2, 0] = Hz_ri_case135(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M)
+    results[:, 2, 1] = Hz_phij_case135(r, r_i, phi_bar_j, phi_bar_Mj, theta_M)
+    return results
+
+
+def case211(phi_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(phi_j), 3, 3))
+    results[:, 0, 1] = Hr_phij_case211(phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case211(phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case212(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r_i), 3, 3))
+    results[:, 0, 0] = Hr_ri_case212(r_i, phi_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 1] = Hr_phij_case212(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case212(r_i, phi_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case212(r_i, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case212(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case212(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case212(r_i, phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case213(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 1] = Hr_phij_case213(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case213(r, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case213(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case213(phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case214(r, phi_j, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case214(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 1] = Hr_phij_case214(phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case214(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case214(r, phi_j, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case214(r, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case214(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case214(r, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case215(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case215(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 0, 1] = Hr_phij_case215(r_bar_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case215(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case215(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 1, 2] = Hphi_zk_case215(r, r_bar_i, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case215(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 2, 1] = Hz_phij_case215(r_bar_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case215(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case221(phi_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(phi_j), 3, 3))
+    results[:, 0, 1] = Hr_phij_case221(phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case221(phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case222(r_i, phi_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r_i), 3, 3))
+    results[:, 0, 0] = Hr_ri_case222(r_i, phi_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 1] = Hr_phij_case222(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case222(r_i, phi_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case222(r_i, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case222(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case222(r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case222(r_i, phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case223(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 1] = Hr_phij_case223(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case223(r, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case223(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case223(r, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 1] = Hr_phij_case224(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case224(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case224(r, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case224(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case224(r, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case224(r, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case225(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case225(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 0, 1] = Hr_phij_case225(r, r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case225(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case225(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 1, 2] = Hphi_zk_case225(r, r_i, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case225(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 2, 1] = Hz_phij_case225(r, r_i, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case225(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case231(phi_j, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    results = np.zeros((len(phi_j), 3, 3))
+    results[:, 0, 1] = Hr_phij_case231(phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 1, 1] = Hphi_phij_case231(phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case231(phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case232(r_i, phi_j, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k):
+    results = np.zeros((len(r_i), 3, 3))
+    results[:, 0, 0] = Hr_ri_case232(
+        r_i, phi_j, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k
+    )
+    results[:, 0, 1] = Hr_phij_case232(r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case232(r_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case232(
+        r_i, phi_j, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k
+    )
+    results[:, 1, 1] = Hphi_phij_case232(r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case232(r_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case232(r_i, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case232(r_i, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case232(r_i, phi_j, theta_M, z_bar_k)
+    return results
+
+
+def case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 1] = Hr_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case233(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 1] = Hphi_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case233(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case233(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case233(r, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case234(r, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 0, 1] = Hr_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case234(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 1, 1] = Hphi_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 1, 2] = Hphi_zk_case234(r, phi_bar_j, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case234(r, phi_bar_j, phi_bar_M, theta_M, z_bar_k)
+    results[:, 2, 1] = Hz_phij_case234(r, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case234(r, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+def case235(r, r_i, r_bar_i, phi_bar_j, phi_bar_M, phi_bar_Mj, theta_M, z_bar_k):
+    results = np.zeros((len(r), 3, 3))
+    results[:, 0, 0] = Hr_ri_case235(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 0, 1] = Hr_phij_case235(r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 0, 2] = Hr_zk_case235(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 1, 0] = Hphi_ri_case235(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 1, 1] = Hphi_phij_case235(
+        r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k
+    )
+    results[:, 1, 2] = Hphi_zk_case235(r, r_i, phi_bar_j, theta_M, z_bar_k)
+    results[:, 2, 0] = Hz_ri_case235(
+        r, r_i, r_bar_i, phi_bar_j, phi_bar_M, theta_M, z_bar_k
+    )
+    results[:, 2, 1] = Hz_phij_case235(r, r_i, phi_bar_j, phi_bar_Mj, theta_M, z_bar_k)
+    results[:, 2, 2] = Hz_zk_case235(r, r_i, r_bar_i, phi_bar_j, theta_M, z_bar_k)
+    return results
+
+
+# CORE
+def magnet_cylinder_segment_Hfield(
+    observers: np.ndarray,
+    dimensions: np.ndarray,
+    magnetizations: np.ndarray,
+) -> np.ndarray:
+    """Magnetic field of homogeneously magnetized cylinder ring segments
+    in Cartesian Coordinates.
+
+    The cylinder axes coincide with the z-axis of the Cylindrical CS and the
+    geometric center of the cylinder lies in the origin. The result is
+    proportional to the magnetization magnitude, and independent of the
+    length units used for dimensions and observers.
+
+    Parameters
+    ----------
+    observers : ndarray, shape (n,3)
+        Observer positions (r,phi,z) in Cylinder coordinates, where phi is
+        given in rad.
+
+    dimensions: ndarray, shape (n,6)
+        Segment dimension [(r1,r2,phi1,phi2,z1,z2), ...]. r1 and r2 are
+        inner and outer radii. phi1 and phi2 are azimuth section angles
+        in rad. z1 and z2 are z-values of bottom and top.
+
+    magnetizations: ndarray, shape (n,3)
+        Magnetization vectors [(M, phi, th), ...] in spherical CS. M is the
+        magnitude of magnetization, phi and th are azimuth and polar angles
+        in rad.
+
+    Returns
+    -------
+    H-field: ndarray, shape (n,3)
+        H-field generated by Cylinder Segments at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.magnet_cylinder_segment_Hfield(
+    ...     observers=np.array([(1,1,2), (0,0,0)]),
+    ...     dimensions=np.array([(1,2,.1,.2,-1,1), (1,2,.3,.9,0,1)]),
+    ...     magnetizations=np.array([(1e7,.1,.2), (1e6,1.1,2.2)]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[-1948.144 32319.944 17616.886]
+     [14167.65   1419.941 17921.646]]
+
+    Notes
+    -----
+    Implementation based on F.Slanovc, Journal of Magnetism and Magnetic
+    Materials, Volume 559, 1 October 2022, 169482
+    """
+
+    # tile inputs into 8-stacks (boundary cases)
+    r, phi, z = np.repeat(observers, 8, axis=0).T
+    r_i = np.repeat(dimensions[:, :2], 4)
+    phi_j = np.repeat(np.tile(dimensions[:, 2:4], 2), 2)
+    z_k = np.ravel(np.tile(dimensions[:, 4:6], 4))
+    _, phi_M, theta_M = np.repeat(magnetizations, 8, axis=0).T
+
+    # initialize results array with nan
+    result = np.empty((len(r), 3, 3))
+    result[:] = np.nan
+
+    # cases to evaluate
+    cases = determine_cases(r, phi, z, r_i, phi_j, z_k)
+
+    # list of all possible cases - excluding the nan-cases 111, 114, 121, 131
+    case_id = np.array(
+        [
+            112,
+            113,
+            115,
+            122,
+            123,
+            124,
+            125,
+            132,
+            133,
+            134,
+            135,
+            211,
+            212,
+            213,
+            214,
+            215,
+            221,
+            222,
+            223,
+            224,
+            225,
+            231,
+            232,
+            233,
+            234,
+            235,
+        ]
+    )
+
+    # corresponding case evaluation functions
+    case_fkt = [
+        case112,
+        case113,
+        case115,
+        case122,
+        case123,
+        case124,
+        case125,
+        case132,
+        case133,
+        case134,
+        case135,
+        case211,
+        case212,
+        case213,
+        case214,
+        case215,
+        case221,
+        case222,
+        case223,
+        case224,
+        case225,
+        case231,
+        case232,
+        case233,
+        case234,
+        case235,
+    ]
+
+    # required case function arguments
+    r_bar_i = r - r_i
+    phi_bar_j = phi - phi_j
+    phi_bar_M = phi_M - phi
+    phi_bar_Mj = phi_M - phi_j
+    z_bar_k = z - z_k
+    #          0   1      2         3          4          5          6        7       8
+    allargs = [
+        r,
+        r_i,
+        r_bar_i,
+        phi_bar_j,
+        phi_bar_M,
+        phi_bar_Mj,
+        theta_M,
+        z_bar_k,
+        phi_j,
+    ]
+    case_args = [
+        (1, 4, 6),
+        (0, 4, 6),
+        (0, 1, 2, 3, 4, 6),
+        (1, 4, 6),
+        (0, 4, 6),
+        (0, 4, 6),
+        (0, 1, 2, 3, 4, 6),
+        (0, 1, 3, 5, 6),
+        (0, 3, 5, 6),
+        (0, 3, 4, 5, 6),
+        (0, 1, 2, 3, 4, 5, 6),
+        (8, 4, 6, 7),
+        (1, 8, 4, 6, 7),
+        (0, 3, 4, 6, 7),
+        (0, 8, 3, 4, 6, 7),
+        (0, 1, 2, 3, 4, 6, 7),
+        (8, 4, 6, 7),
+        (1, 8, 4, 6, 7),
+        (0, 3, 4, 6, 7),
+        (0, 3, 4, 6, 7),
+        (0, 1, 2, 3, 4, 6, 7),
+        (8, 3, 5, 6, 7),
+        (1, 8, 3, 4, 5, 6, 7),
+        (0, 3, 5, 6, 7),
+        (0, 3, 4, 5, 6, 7),
+        (0, 1, 2, 3, 4, 5, 6, 7),
+    ]
+
+    # calling case functions with respective masked arguments
+    for cid, cfkt, cargs in zip(case_id, case_fkt, case_args, strict=False):
+        mask = cases == cid
+        if any(mask):
+            result[mask] = cfkt(*[allargs[aid][mask] for aid in cargs])
+
+    # sum up contributions from different boundary cases (ax1) and different face types (ax3)
+    result = np.reshape(result, (-1, 8, 3, 3))
+    result = np.sum(result[:, (1, 2, 4, 7)] - result[:, (0, 3, 5, 6)], axis=(1, 3))
+
+    # multiply with magnetization amplitude
+    result = result.T * magnetizations[:, 0] * 1e-7 / MU0
+
+    return result.T
+
+
+def BHJM_cylinder_segment_internal(
+    field: str,
+    observers: np.ndarray,
+    polarization: np.ndarray,
+    dimension: np.ndarray,
+) -> np.ndarray:
+    """
+    internal version of BHJM_cylinder_segment used for object oriented interface.
+
+    Falls back to magnet_cylinder_field whenever the section angles describe the full
+    360° cylinder.
+    """
+
+    BHfinal = np.zeros_like(observers, dtype=float)
+
+    r1, r2, h, phi1, phi2 = dimension.T
+
+    # case1: segment
+    mask1 = (phi2 - phi1) < 360
+
+    BHfinal[mask1] = BHJM_cylinder_segment(
+        field=field,
+        observers=observers[mask1],
+        polarization=polarization[mask1],
+        dimension=dimension[mask1],
+    )
+
+    # case2: full cylinder
+    mask1x = ~mask1
+    BHfinal[mask1x] = BHJM_magnet_cylinder(
+        field=field,
+        observers=observers[mask1x],
+        polarization=polarization[mask1x],
+        dimension=np.c_[2 * r2[mask1x], h[mask1x]],
+    )
+
+    # case2a: hollow cylinder <- should be vectorized together with above
+    mask2 = (r1 != 0) & mask1x
+    BHfinal[mask2] -= BHJM_magnet_cylinder(
+        field=field,
+        observers=observers[mask2],
+        polarization=polarization[mask2],
+        dimension=np.c_[2 * r1[mask2], h[mask2]],
+    )
+
+    return BHfinal
+
+
+def BHJM_cylinder_segment(
+    field: str,
+    observers: np.ndarray,
+    dimension: np.ndarray,
+    polarization: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate cylinder segment field to BHJM
+    - special cases catching
+    """
+    check_field_input(field)
+
+    BHJM = polarization.astype(float)
+
+    r1, r2, h, phi1, phi2 = dimension.T
+    r1 = abs(r1)
+    r2 = abs(r2)
+    h = abs(h)
+    z1, z2 = -h / 2, h / 2
+
+    # transform dim deg->rad
+    phi1 = phi1 / 180 * np.pi
+    phi2 = phi2 / 180 * np.pi
+    dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+
+    # transform obs_pos to Cy CS --------------------------------------------
+    x, y, z = observers.T
+    r, phi = np.sqrt(x**2 + y**2), np.arctan2(y, x)
+    pos_obs_cy = np.concatenate(((r,), (phi,), (z,)), axis=0).T
+
+    # determine when points lie inside and on surface of magnet -------------
+
+    # mask_inside = None
+    # if in_out == "auto":
+    # phip1 in [-2pi,0], phio2 in [0,2pi]
+    phio1 = phi
+    phio2 = phi - np.sign(phi) * 2 * np.pi
+
+    # phi=phi1, phi=phi2
+    mask_phi1 = close(phio1, phi1) | close(phio2, phi1)
+    mask_phi2 = close(phio1, phi2) | close(phio2, phi2)
+
+    # r, phi ,z lies in-between, avoid numerical fluctuations (e.g. due to rotations) by including 1e-14
+    mask_r_in = (r1 - 1e-14 < r) & (r < r2 + 1e-14)
+    mask_phi_in = (np.sign(phio1 - phi1) != np.sign(phio1 - phi2)) | (
+        np.sign(phio2 - phi1) != np.sign(phio2 - phi2)
+    )
+    mask_z_in = (z1 - 1e-14 < z) & (z < z2 + 1e-14)
+
+    # on surface
+    mask_surf_z = (
+        (close(z, z1) | close(z, z2)) & mask_phi_in & mask_r_in
+    )  # top / bottom
+    mask_surf_r = (close(r, r1) | close(r, r2)) & mask_phi_in & mask_z_in  # in / out
+    mask_surf_phi = (mask_phi1 | mask_phi2) & mask_r_in & mask_z_in  # in / out
+    mask_not_on_surf = ~(mask_surf_z | mask_surf_r | mask_surf_phi)
+
+    # inside
+    mask_inside = mask_r_in & mask_phi_in & mask_z_in
+    # else:
+    #     mask_inside = np.full(len(observers), in_out == "inside")
+    #     mask_not_on_surf = np.full(len(observers), True)
+    # WARNING @alex
+    #   1. inside and not_on_surface are not the same! Can't just put to true.
+
+    # return 0 when all points are on surface
+    if not np.any(mask_not_on_surf):
+        return BHJM * 0
+
+    if field == "J":
+        BHJM[~mask_inside] = 0
+        return BHJM
+
+    if field == "M":
+        BHJM[~mask_inside] = 0
+        return BHJM / MU0
+
+    BHJM *= 0
+
+    # redefine input if there are some surface-points -------------------------
+    pol = polarization[mask_not_on_surf]
+    dim = dim[mask_not_on_surf]
+    pos_obs_cy = pos_obs_cy[mask_not_on_surf]
+    phi = phi[mask_not_on_surf]
+
+    # transform mag to spherical CS -----------------------------------------
+    m = np.sqrt(pol[:, 0] ** 2 + pol[:, 1] ** 2 + pol[:, 2] ** 2) / MU0  # J -> M
+    phi_m = np.arctan2(pol[:, 1], pol[:, 0])
+    th_m = np.arctan2(np.sqrt(pol[:, 0] ** 2 + pol[:, 1] ** 2), pol[:, 2])
+    mag_sph = np.concatenate(((m,), (phi_m,), (th_m,)), axis=0).T
+
+    # compute H and transform to cart CS -------------------------------------
+    H_cy = magnet_cylinder_segment_Hfield(
+        magnetizations=mag_sph, dimensions=dim, observers=pos_obs_cy
+    )
+    Hr, Hphi, Hz = H_cy.T
+    Hx = Hr * np.cos(phi) - Hphi * np.sin(phi)
+    Hy = Hr * np.sin(phi) + Hphi * np.cos(phi)
+    BHJM[mask_not_on_surf] = np.concatenate(((Hx,), (Hy,), (Hz,)), axis=0).T
+
+    if field == "H":
+        return BHJM
+
+    if field == "B":
+        BHJM *= MU0
+        BHJM[mask_inside] += polarization[mask_inside]
+        BHJM[~mask_not_on_surf] *= 0
+        return BHJM
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
+
+    # return convert_HBMJ(
+    #     output_field_type=field,
+    #     polarization=polarization,
+    #     input_field_type="H",
+    #     field_values=H_all,
+    #     mask_inside=mask_inside & mask_not_on_surf,
+    # )
diff --git a/src/magpylib/_src/fields/field_BH_dipole.py b/src/magpylib/_src/fields/field_BH_dipole.py
new file mode 100644
index 000000000..07196bc76
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_dipole.py
@@ -0,0 +1,106 @@
+"""
+Core implementation of dipole field
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.input_checks import check_field_input
+
+
+# CORE
+def dipole_Hfield(
+    observers: np.ndarray,
+    moments: np.ndarray,
+) -> np.ndarray:
+    """Magnetic field of a dipole moments.
+
+    The dipole moment lies in the origin of the coordinate system.
+    The output is proportional to the moment input, and is independent
+    of length units used for observers (and moment) input considering
+    that the moment is proportional to [L]**2.
+    Returns np.inf for all non-zero moment components in the origin.
+
+    Parameters
+    ----------
+    observers: ndarray, shape (n,3)
+        Observer positions (x,y,z) in Cartesian coordinates.
+
+    moments: ndarray, shape (n,3)
+        Dipole moment vector.
+
+    Returns
+    -------
+    H-field: ndarray, shape (n,3)
+        H-field of Dipole in Cartesian coordinates.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> H = magpy.core.dipole_Hfield(
+    ...    observers=np.array([(1,1,1), (2,2,2)]),
+    ...    moments=np.array([(1e5,0,0), (0,0,1e5)]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [[2.895e-13 1.531e+03 1.531e+03]
+     [1.914e+02 1.914e+02 3.619e-14]]
+
+    Notes
+    -----
+    The moment of a magnet is given by its volume*magnetization.
+    """
+
+    x, y, z = observers.T
+    r = np.sqrt(x**2 + y**2 + z**2)  # faster than np.linalg.norm
+    with np.errstate(divide="ignore", invalid="ignore"):
+        # 0/0 produces invalid warn and results in np.nan
+        # x/0 produces divide warn and results in np.inf
+        H = (
+            (
+                3 * np.sum(moments * observers, axis=1) * observers.T / r**5
+                - moments.T / r**3
+            ).T
+            / 4
+            / np.pi
+        )
+
+    # when r=0 return np.inf in all non-zero moments directions
+    mask1 = r == 0
+    if np.any(mask1):
+        with np.errstate(divide="ignore", invalid="ignore"):
+            H[mask1] = moments[mask1] / 0.0
+            np.nan_to_num(H, copy=False, posinf=np.inf, neginf=-np.inf)
+
+    return H
+
+
+def BHJM_dipole(
+    field: str,
+    observers: np.ndarray,
+    moment: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate dipole field to BHJM
+    """
+    check_field_input(field)
+
+    if field in "MJ":
+        return np.zeros_like(observers, dtype=float)
+
+    BHJM = dipole_Hfield(
+        observers=observers,
+        moments=moment,
+    )
+
+    if field == "H":
+        return BHJM
+
+    if field == "B":
+        return BHJM * MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_polyline.py b/src/magpylib/_src/fields/field_BH_polyline.py
new file mode 100644
index 000000000..7d998a321
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_polyline.py
@@ -0,0 +1,246 @@
+"""
+Implementations of analytical expressions of line current segments
+"""
+
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numpy as np
+from numpy.linalg import norm
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.input_checks import check_field_input
+
+
+def current_vertices_field(
+    field: str,
+    observers: np.ndarray,
+    current: np.ndarray,
+    vertices: np.ndarray = None,
+    segment_start=None,  # list of mix3 ndarrays
+    segment_end=None,
+) -> np.ndarray:
+    """
+    This function accepts n (mi,3) shaped vertex-sets, creates a single long
+    input array for field_BH_polyline(), computes, sums and returns a single field for each
+    vertex-set at respective n observer positions.
+
+    ### Args:
+    - bh (boolean): True=B, False=H
+    - current (ndarray n): current on line in units of A
+    - vertex_sets (list of len n): n vertex sets (each of shape (mi,3))
+    - pos_obs (ndarray nx3): n observer positions in units of m
+
+    ### Returns:
+    - B-field (ndarray nx3): B-field vectors at pos_obs in units of T
+    """
+    if vertices is None:
+        return BHJM_current_polyline(
+            field=field,
+            observers=observers,
+            current=current,
+            segment_start=segment_start,
+            segment_end=segment_end,
+        )
+
+    nvs = np.array([f.shape[0] for f in vertices])  # lengths of vertices sets
+    if all(v == nvs[0] for v in nvs):  # if all vertices sets have the same lengths
+        n0, n1, *_ = vertices.shape
+        BH = BHJM_current_polyline(
+            field=field,
+            observers=np.repeat(observers, n1 - 1, axis=0),
+            current=np.repeat(current, n1 - 1, axis=0),
+            segment_start=vertices[:, :-1].reshape(-1, 3),
+            segment_end=vertices[:, 1:].reshape(-1, 3),
+        )
+        BH = BH.reshape((n0, n1 - 1, 3))
+        BH = np.sum(BH, axis=1)
+    else:
+        split_indices = np.cumsum(nvs - 1)[:-1]  # remove last to avoid empty split
+        BH = BHJM_current_polyline(
+            field=field,
+            observers=np.repeat(observers, nvs - 1, axis=0),
+            current=np.repeat(current, nvs - 1, axis=0),
+            segment_start=np.concatenate([vert[:-1] for vert in vertices]),
+            segment_end=np.concatenate([vert[1:] for vert in vertices]),
+        )
+        bh_split = np.split(BH, split_indices)
+        BH = np.array([np.sum(bh, axis=0) for bh in bh_split])
+    return BH
+
+
+# CORE
+def current_polyline_Hfield(
+    observers: np.ndarray,
+    segments_start: np.ndarray,
+    segments_end: np.ndarray,
+    currents: np.ndarray,
+) -> np.ndarray:
+    """B-field of line current segments.
+
+    The current flows from start to end positions. The field is set to (0,0,0) on a
+    line segment. The output is proportional to the current and independent of the
+    length units chosen for observers and dimensions.
+
+    Parameters
+    ----------
+    observers: ndarray, shape (n,3)
+        Observer positions (x,y,z) in Cartesian coordinates.
+
+    segments_start: ndarray, shape (n,3)
+        Polyline start positions (x,y,z) in Cartesian coordinates.
+
+    segments_end: ndarray, shape (n,3)
+        Polyline end positions (x,y,z) in Cartesian coordinates.
+
+    currents: ndarray, shape (n,)
+        Electrical currents in segments.
+
+    Returns
+    -------
+    B-Field: ndarray, shape (n,3)
+        B-field generated by current segments at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> H = magpy.core.current_polyline_Hfield(
+    ...     observers=np.array([(1,1,1), (2,2,2)]),
+    ...     segments_start=np.array([(0,0,0), (0,0,0)]),
+    ...     segments_end=np.array([(1,0,0), (-1,0,0)]),
+    ...     currents=np.array([100,200]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [[ 0.    -2.297  2.297]
+     [ 0.     0.598 -0.598]]
+
+    Notes
+    -----
+    Field computation via law of Biot Savart. See also countless online resources.
+    eg. http://www.phys.uri.edu/gerhard/PHY204/tsl216.pdf
+
+    Be careful with magnetic fields of discontinued segments. They are
+    unphysical and can lead to unphysical effects.
+    """
+    # rename
+    p1, p2, po = segments_start, segments_end, observers
+
+    # make dimensionless (avoid all large/small input problems) by introducing
+    # the segment length as characteristic length scale.
+    norm_12 = norm(p1 - p2, axis=1)
+    p1 = (p1.T / norm_12).T
+    p2 = (p2.T / norm_12).T
+    po = (po.T / norm_12).T
+
+    # p4 = projection of pos_obs onto line p1-p2
+    t = np.sum((po - p1) * (p1 - p2), axis=1)
+    p4 = p1 + (t * (p1 - p2).T).T
+
+    # distance of observers from line
+    norm_o4 = norm(po - p4, axis=1)
+
+    # separate on-line cases (-> B=0)
+    mask1 = norm_o4 < 1e-15  # account for numerical issues
+
+    # continue only with general off-line cases
+    if np.any(mask1):
+        not_mask1 = ~mask1
+        po = po[not_mask1]
+        p1 = p1[not_mask1]
+        p2 = p2[not_mask1]
+        p4 = p4[not_mask1]
+        norm_12 = norm_12[not_mask1]
+        norm_o4 = norm_o4[not_mask1]
+        currents = currents[not_mask1]
+
+    # determine field direction
+    cros_ = np.cross(p2 - p1, po - p4)
+    norm_cros = norm(cros_, axis=1)
+    eB = (cros_.T / norm_cros).T
+
+    # compute angles
+    norm_o1 = norm(
+        po - p1, axis=1
+    )  # improve performance by computing all norms at once
+    norm_o2 = norm(po - p2, axis=1)
+    norm_41 = norm(p4 - p1, axis=1)
+    norm_42 = norm(p4 - p2, axis=1)
+    sinTh1 = norm_41 / norm_o1
+    sinTh2 = norm_42 / norm_o2
+    deltaSin = np.empty((len(po),))
+
+    # determine how p1,p2,p4 are sorted on the line (to get sinTH signs)
+    # both points below
+    mask2 = (norm_41 > 1) * (norm_41 > norm_42)
+    deltaSin[mask2] = abs(sinTh1[mask2] - sinTh2[mask2])
+    # both points above
+    mask3 = (norm_42 > 1) * (norm_42 > norm_41)
+    deltaSin[mask3] = abs(sinTh2[mask3] - sinTh1[mask3])
+    # one above one below or one equals p4
+    mask4 = ~mask2 * ~mask3
+    deltaSin[mask4] = abs(sinTh1[mask4] + sinTh2[mask4])
+
+    # B = (deltaSin / norm_o4 * eB.T / norm_12 * current * 1e-7).T
+
+    # avoid array creation if possible
+    if np.any(mask1):
+        H = np.zeros_like(observers, dtype=float)
+        H[~mask1] = (deltaSin / norm_o4 * eB.T / norm_12 * currents / (4 * np.pi)).T
+        return H
+    return (deltaSin / norm_o4 * eB.T / norm_12 * currents / (4 * np.pi)).T
+
+
+def BHJM_current_polyline(
+    field: str,
+    observers: np.ndarray,
+    segment_start: np.ndarray,
+    segment_end: np.ndarray,
+    current: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate Polyline field to BHJM
+    - treat some special cases
+    """
+    # pylint: disable=too-many-statements
+
+    check_field_input(field)
+
+    BHJM = np.zeros_like(observers, dtype=float)
+
+    if field in "MJ":
+        return BHJM
+
+    # Check for zero-length segments (or discontinuous)
+    mask_nan_start = np.isnan(segment_start).all(axis=1)
+    mask_nan_end = np.isnan(segment_end).all(axis=1)
+    mask_equal = np.all(segment_start == segment_end, axis=1)
+    mask0 = mask_equal | mask_nan_start | mask_nan_end
+    not_mask0 = ~mask0  # avoid multiple computation of ~mask
+
+    if np.all(mask0):
+        return BHJM
+
+    # continue only with non-zero segments
+    if np.any(mask0):
+        current = current[not_mask0]
+        segment_start = segment_start[not_mask0]
+        segment_end = segment_end[not_mask0]
+        observers = observers[not_mask0]
+
+    BHJM[not_mask0] = current_polyline_Hfield(
+        observers=observers,
+        segments_start=segment_start,
+        segments_end=segment_end,
+        currents=current,
+    )
+
+    if field == "H":
+        return BHJM
+
+    if field == "B":
+        return BHJM * MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_sphere.py b/src/magpylib/_src/fields/field_BH_sphere.py
new file mode 100644
index 000000000..585ba1e2d
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_sphere.py
@@ -0,0 +1,119 @@
+"""
+Implementations of analytical expressions for the magnetic field of homogeneously
+magnetized Spheres. Computation details in function docstrings.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.input_checks import check_field_input
+
+
+# CORE
+def magnet_sphere_Bfield(
+    observers: np.ndarray,
+    diameters: np.ndarray,
+    polarizations: np.ndarray,
+) -> np.ndarray:
+    """Magnetic field of homogeneously magnetized spheres in Cartesian Coordinates.
+
+    The center of the spheres lie in the origin of the coordinate system. The output
+    is proportional to the polarization magnitudes, and independent of the length
+    units chosen for observers and diameters.
+
+    Parameters
+    ----------
+    observers: ndarray, shape (n,3)
+        Observer positions (x,y,z) in Cartesian coordinates.
+
+    diameters: ndarray, shape (n,)
+        Sphere diameters.
+
+    polarizations: ndarray, shape (n,3)
+        Magnetic polarization vectors.
+
+    Returns
+    -------
+    B-field: ndarray, shape (n,3)
+        B-field generated by Spheres at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.magnet_sphere_Bfield(
+    ...     observers=np.array([(1,1,1), (2,2,2)]),
+    ...     diameters=np.array([1,2]),
+    ...     polarizations=np.array([(1,0,0), (1,1,0)]),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[1.187e-18 8.019e-03 8.019e-03]
+     [8.019e-03 8.019e-03 1.604e-02]]
+
+    Notes
+    -----
+    The field corresponds to a dipole field on the outside and is 2/3*mag
+    in the inside (see e.g. "Theoretical Physics, Bertelmann").
+    """
+    return BHJM_magnet_sphere(
+        field="B",
+        observers=observers,
+        diameter=diameters,
+        polarization=polarizations,
+    )
+
+
+def BHJM_magnet_sphere(
+    field: str,
+    observers: np.ndarray,
+    diameter: np.ndarray,
+    polarization: np.ndarray,
+) -> np.ndarray:
+    """
+    - compute sphere field and translate to BHJM
+    - magnet sphere field, cannot be moved to a core function, because
+    core computation requires inside-outside check, but BHJM translation also.
+    Would require 2 checks, or forwarding the masks ... both not ideal
+    """
+    check_field_input(field)
+
+    x, y, z = np.copy(observers.T)
+    r = np.sqrt(x**2 + y**2 + z**2)  # faster than np.linalg.norm
+    r_sphere = abs(diameter) / 2
+
+    # inside field & allocate
+    BHJM = polarization.astype(float)
+    out = r > r_sphere
+
+    if field == "J":
+        BHJM[out] = 0.0
+        return BHJM
+
+    if field == "M":
+        BHJM[out] = 0.0
+        return BHJM / MU0
+
+    BHJM *= 2 / 3
+
+    BHJM[out] = (
+        (
+            3 * np.sum(polarization[out] * observers[out], axis=1) * observers[out].T
+            - polarization[out].T * r[out] ** 2
+        )
+        / r[out] ** 5
+        * r_sphere[out] ** 3
+        / 3
+    ).T
+
+    if field == "B":
+        return BHJM
+
+    if field == "H":
+        BHJM[~out] -= polarization[~out]
+        return BHJM / MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_tetrahedron.py b/src/magpylib/_src/fields/field_BH_tetrahedron.py
new file mode 100644
index 000000000..9e7a2bf0b
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_tetrahedron.py
@@ -0,0 +1,130 @@
+"""
+Implementation for the magnetic field of homogeneously
+magnetized tetrahedra. Computation details in function docstrings.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+from magpylib._src.input_checks import check_field_input
+
+
+def check_chirality(points: np.ndarray) -> np.ndarray:
+    """
+    Checks if quadruple of points (p0,p1,p2,p3) that forms tetrahedron is arranged in a way
+    that the vectors p0p1, p0p2, p0p3 form a right-handed system
+
+    Parameters
+    -----------
+    points: 3d-array of shape (m x 4 x 3)
+            m...number of tetrahedrons
+
+    Returns
+    ----------
+    new list of points, where p2 and p3 are possibly exchanged so that all
+    tetrahedron is given in a right-handed system.
+    """
+
+    vecs = np.zeros((len(points), 3, 3))
+    vecs[:, :, 0] = points[:, 1, :] - points[:, 0, :]
+    vecs[:, :, 1] = points[:, 2, :] - points[:, 0, :]
+    vecs[:, :, 2] = points[:, 3, :] - points[:, 0, :]
+
+    dets = np.linalg.det(vecs)
+    dets_neg = dets < 0
+
+    if np.any(dets_neg):
+        points[dets_neg, 2:, :] = points[dets_neg, 3:1:-1, :]
+
+    return points
+
+
+def point_inside(points: np.ndarray, vertices: np.ndarray, in_out: str) -> np.ndarray:
+    """
+    Takes points, as well as the vertices of a tetrahedra.
+    Returns boolean array indicating whether the points are inside the tetrahedra.
+    """
+    if in_out == "inside":
+        return np.array([True] * len(points))
+
+    if in_out == "outside":
+        return np.array([False] * len(points))
+
+    mat = vertices[:, 1:].swapaxes(0, 1) - vertices[:, 0]
+    mat = np.transpose(mat.swapaxes(0, 1), (0, 2, 1))
+
+    tetra = np.linalg.inv(mat)
+    newp = np.matmul(tetra, np.reshape(points - vertices[:, 0, :], (*points.shape, 1)))
+    return (
+        np.all(newp >= 0, axis=1)
+        & np.all(newp <= 1, axis=1)
+        & (np.sum(newp, axis=1) <= 1)
+    ).flatten()
+
+
+def BHJM_magnet_tetrahedron(
+    field: str,
+    observers: np.ndarray,
+    vertices: np.ndarray,
+    polarization: np.ndarray,
+    in_out="auto",
+) -> np.ndarray:
+    """
+    - compute tetrahedron field from Triangle field
+    - translate to BHJM
+    - treat special cases
+    """
+
+    check_field_input(field)
+
+    # allocate - try not to generate more arrays
+    BHJM = polarization.astype(float)
+
+    if field == "J":
+        mask_inside = point_inside(observers, vertices, in_out)
+        BHJM[~mask_inside] = 0
+        return BHJM
+
+    if field == "M":
+        mask_inside = point_inside(observers, vertices, in_out)
+        BHJM[~mask_inside] = 0
+        return BHJM / MU0
+
+    vertices = check_chirality(vertices)
+
+    tri_vertices = np.concatenate(
+        (
+            vertices[:, (0, 2, 1), :],
+            vertices[:, (0, 1, 3), :],
+            vertices[:, (1, 2, 3), :],
+            vertices[:, (0, 3, 2), :],
+        ),
+        axis=0,
+    )
+    tri_field = BHJM_triangle(
+        field=field,
+        observers=np.tile(observers, (4, 1)),
+        vertices=tri_vertices,
+        polarization=np.tile(polarization, (4, 1)),
+    )
+    n = len(observers)
+    BHJM = (  # slightly faster than reshape + sum
+        tri_field[:n]
+        + tri_field[n : 2 * n]
+        + tri_field[2 * n : 3 * n]
+        + tri_field[3 * n :]
+    )
+
+    if field == "H":
+        return BHJM
+
+    if field == "B":
+        mask_inside = point_inside(observers, vertices, in_out)
+        BHJM[mask_inside] += polarization[mask_inside]
+        return BHJM
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_triangle.py b/src/magpylib/_src/fields/field_BH_triangle.py
new file mode 100644
index 000000000..c311a330b
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_triangle.py
@@ -0,0 +1,219 @@
+"""
+Implementations of analytical expressions for the magnetic field of a triangular surface.
+Computation details in function docstrings.
+"""
+
+# pylance: disable=Code is unreachable
+from __future__ import annotations
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.input_checks import check_field_input
+
+
+def vcross3(a: np.ndarray, b: np.ndarray) -> np.ndarray:
+    """
+    vectorized cross product for 3d vectors. Is ~4x faster than np.cross when
+    arrays are smallish. Only slightly faster for large arrays.
+    input shape a,b: (n,3)
+    returns: (n, 3)
+    """
+    # receives nan values at corners
+    with np.errstate(invalid="ignore"):
+        result = np.array(
+            [
+                a[:, 1] * b[:, 2] - a[:, 2] * b[:, 1],
+                a[:, 2] * b[:, 0] - a[:, 0] * b[:, 2],
+                a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0],
+            ]
+        )
+    return result.T
+
+
+def norm_vector(v) -> np.ndarray:
+    """
+    Calculates normalized orthogonal vector on a plane defined by three vertices.
+    """
+    a = v[:, 1] - v[:, 0]
+    b = v[:, 2] - v[:, 0]
+    n = vcross3(a, b)
+    n_norm = np.linalg.norm(n, axis=-1)
+    return n / np.expand_dims(n_norm, axis=-1)
+
+
+def solid_angle(R: np.ndarray, r: np.ndarray) -> np.ndarray:
+    """
+    Vectorized computation of the solid angle of triangles.
+
+    Triangle point indices are 1,2,3, different triangles are denoted by a,b,c,...
+    The first triangle is defined as R1a, R2a, R3a.
+
+    Input:
+    R = [(R1a, R1b, R1c, ...), (R2a, R2b, R2c, ...), (R3a, R3b, R3c, ...)]
+    r = [(|R1a|, |R1b|, |R1c|, ...), (|R2a|, |R2b|, |R2c|, ...), (|R3a|, |R3b|, |R3c|, ...)]
+
+    Returns:
+    [sangle_a, sangle_b, sangle_c, ...]
+    """
+
+    # Calculates (oriented) volume of the parallelepiped in vectorized form.
+    N = np.einsum("ij, ij->i", R[2], vcross3(R[1], R[0]))
+
+    D = (
+        r[0] * r[1] * r[2]
+        + np.einsum("ij, ij->i", R[2], R[1]) * r[0]
+        + np.einsum("ij, ij->i", R[2], R[0]) * r[1]
+        + np.einsum("ij, ij->i", R[1], R[0]) * r[2]
+    )
+    result = 2.0 * np.arctan2(N, D)
+
+    # modulus 2pi to avoid jumps on edges in line
+    # "B = sigma * ((n.T * solid_angle(R, r)) - vcross3(n, PQR).T)"
+    # <-- bad fix :(
+
+    return np.where(abs(result) > 6.2831853, 0, result)
+
+
+def triangle_Bfield(
+    observers: np.ndarray,
+    vertices: np.ndarray,
+    polarizations: np.ndarray,
+) -> np.ndarray:
+    """Magnetic field generated by homogeneously magnetically charged triangular surfaces.
+
+    The charge is proportional to the projection of the polarization vectors onto the
+    triangle surfaces. The order of the triangle vertices defines the sign of the
+    surface normal vector (right-hand-rule). The output is proportional to the
+    polarization magnitude, and independent of the length units chosen for observers
+    and vertices.
+
+    Can be used to compute the field of a homogeneously magnetized bodies with triangular
+    surface mesh. In this case each Triangle must be defined so that the surface normal
+    vector points outwards.
+
+    Parameters
+    ----------
+    observers: ndarray, shape (n,3)
+        Observer positions (x,y,z) in Cartesian coordinates.
+
+    vertices: ndarray, shape (n,3,3)
+        Triangle vertex positions ((P11,P12,P13), (P21, P22, P23), ...) in Cartesian
+        coordinates.
+
+    polarizations: ndarray, shape (n,3)
+        Magnetic polarization vectors.
+
+    Returns
+    -------
+    B-field: ndarray, shape (n,3)
+        B-field generated by Triangles at observer positions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> B = magpy.core.triangle_Bfield(
+    ...    observers=np.array([(2,1,1), (2,2,2)]),
+    ...    vertices=np.array([[(0,0,0),(0,0,1),(1,0,0)], [(0,0,0),(0,0,1),(1,0,0)]]),
+    ...    polarizations=np.array([(1,1,1), (1,1,0)])*1e3,
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...    print(B)
+    [[7.452 4.62  3.136]
+     [2.213 2.677 2.213]]
+
+    Notes
+    -----
+    Field computations implemented from Guptasarma, Geophysics, 1999, 64:1, 70-74.
+    Corners give (nan, nan, nan). Edges and in-plane perp components are set to 0.
+    Loss of precision when approaching a triangle as (x-edge)**2 :(
+    Loss of precision with distance from the triangle as distance**3 :(
+    """
+    n = norm_vector(vertices)
+    sigma = np.einsum("ij, ij->i", n, polarizations)  # vectorized inner product
+
+    # vertex <-> observer
+    R = np.swapaxes(vertices, 0, 1) - observers
+    r2 = np.sum(R * R, axis=-1)
+    r = np.sqrt(r2)
+
+    # vertex <-> vertex
+    L = vertices[:, (1, 2, 0)] - vertices[:, (0, 1, 2)]
+    L = np.swapaxes(L, 0, 1)
+    l2 = np.sum(L * L, axis=-1)
+    l1 = np.sqrt(l2)
+
+    # vert-vert -- vert-obs
+    b = np.einsum("ijk, ijk->ij", R, L)
+    bl = b / l1
+    ind = np.fabs(r + bl)  # closeness measure to corner and edge
+
+    # The computation of ind is the origin of a major numerical instability
+    #    when approaching the triangle because r ~ -bl. This number
+    #    becomes small at the same rate as it looses precision.
+    #    This is a major problem, because at distances 1e-8 and 1e8 all precision
+    #    is already lost !!!
+    # The second problem is at corner and edge extensions where ind also computes
+    #    as 0. Here one approaches a special case where another evaluation should
+    #    be used. This problem is solved in the following lines.
+    # np.seterr must be used because of a numpy bug. It does not interpret where
+    #   correctly. The following code will raise a numpy warning - but obviously shouldn't
+    #
+    # x = np.array([(0,1,2), (0,0,1)])
+    # np.where(
+    #     x>0,
+    #     1/x,
+    #     0
+    # )
+
+    with np.errstate(divide="ignore", invalid="ignore"):
+        I = np.where(  # noqa: E741
+            ind > 1.0e-12,
+            1.0 / l1 * np.log((np.sqrt(l2 + 2 * b + r2) + l1 + bl) / ind),
+            -(1.0 / l1) * np.log(np.fabs(l1 - r) / r),
+        )
+    PQR = np.einsum("ij, ijk -> jk", I, L)
+    B = sigma * (n.T * solid_angle(R, r) - vcross3(n, PQR).T)
+    B = B / np.pi / 4.0
+
+    return B.T
+
+
+def BHJM_triangle(
+    field: str,
+    observers: np.ndarray,
+    vertices: np.ndarray,
+    polarization: np.ndarray,
+) -> np.ndarray:
+    """
+    - translate triangle core field to BHJM
+    """
+    check_field_input(field)
+
+    BHJM = polarization.astype(float) * 0.0
+
+    if field == "M":
+        return BHJM
+
+    if field == "J":
+        return BHJM
+
+    BHJM = triangle_Bfield(
+        observers=observers,
+        vertices=vertices,
+        polarizations=polarization,
+    )
+
+    # new MU0 problem:
+    #   input is polarization -> output has MU0 on it and must be B
+    #   H will then be connected via MU0
+
+    if field == "B":
+        return BHJM
+
+    if field == "H":
+        return BHJM / MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_BH_triangularmesh.py b/src/magpylib/_src/fields/field_BH_triangularmesh.py
new file mode 100644
index 000000000..0ea1d014f
--- /dev/null
+++ b/src/magpylib/_src/fields/field_BH_triangularmesh.py
@@ -0,0 +1,577 @@
+"""
+Implementations of analytical expressions for the magnetic field of a triangular surface.
+Computation details in function docstrings.
+"""
+
+# pylint: disable=too-many-nested-blocks
+# pylint: disable=too-many-branches
+# pylance: disable=Code is unreachable
+from __future__ import annotations
+
+import numpy as np
+import scipy.spatial
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+
+
+def calculate_centroid(vertices, faces):
+    """
+    Calculates the centroid of a 3D triangular surface mesh.
+
+    Parameters:
+    vertices (numpy.array): an n x 3 array of vertices
+    faces (numpy.array): an m x 3 array of triangle indices
+
+    Returns:
+    numpy.array: The centroid of the mesh
+    """
+
+    # Calculate the centroids of each triangle
+    triangle_centroids = np.mean(vertices[faces], axis=1)
+
+    # Compute the area of each triangle
+    triangle_areas = 0.5 * np.linalg.norm(
+        np.cross(
+            vertices[faces[:, 1]] - vertices[faces[:, 0]],
+            vertices[faces[:, 2]] - vertices[faces[:, 0]],
+        ),
+        axis=1,
+    )
+
+    # Calculate the centroid of the entire mesh
+    return np.sum(triangle_centroids.T * triangle_areas, axis=1) / np.sum(
+        triangle_areas
+    )
+
+
+def v_norm2(a: np.ndarray) -> np.ndarray:
+    """
+    return |a|**2
+    """
+    a = a * a
+    return a[..., 0] + a[..., 1] + a[..., 2]
+
+
+def v_norm_proj(a: np.ndarray, b: np.ndarray) -> np.ndarray:
+    """
+    return a_dot_b/|a||b|
+    assuming that |a|, |b| > 0
+    """
+    ab = a * b
+    ab = ab[..., 0] + ab[..., 1] + ab[..., 2]
+
+    return ab / np.sqrt(v_norm2(a) * v_norm2(b))
+
+
+def v_cross(a: np.ndarray, b: np.ndarray) -> np.ndarray:
+    """
+    a x b
+    """
+    return np.array(
+        (
+            a[:, 1] * b[:, 2] - a[:, 2] * b[:, 1],
+            a[:, 2] * b[:, 0] - a[:, 0] * b[:, 2],
+            a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0],
+        )
+    ).T
+
+
+def v_dot_cross3d(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> np.ndarray:
+    """
+    a x b * c
+    """
+    return (
+        (a[..., 1] * b[..., 2] - a[..., 2] * b[..., 1]) * c[..., 0]
+        + (a[..., 2] * b[..., 0] - a[..., 0] * b[..., 2]) * c[..., 1]
+        + (a[..., 0] * b[..., 1] - a[..., 1] * b[..., 0]) * c[..., 2]
+    )
+
+
+def get_disconnected_faces_subsets(faces: list) -> list:
+    """Return a list of disconnected faces sets"""
+    subsets_inds = []
+    tria_temp = faces.copy()
+    while len(tria_temp) > 0:
+        first, *rest = tria_temp
+        first = set(first)
+        lf = -1
+        while len(first) > lf:
+            lf = len(first)
+            rest2 = []
+            for r in rest:
+                if len(first.intersection(set(r))) > 0:
+                    first |= set(r)
+                else:
+                    rest2.append(r)
+            rest = rest2
+        subsets_inds.append(list(first))
+        tria_temp = rest
+    return [faces[np.isin(faces, list(ps)).all(axis=1)] for ps in subsets_inds]
+
+
+def get_open_edges(faces: np.ndarray) -> bool:
+    """
+    Check if given trimesh forms a closed surface.
+
+    Input: faces: np.ndarray, shape (n,3), dtype int
+        triples of indices
+
+    Output: open edges
+    """
+    edges = np.concatenate([faces[:, 0:2], faces[:, 1:3], faces[:, ::2]], axis=0)
+
+    # unique edge pairs and counts how many
+    edges = np.sort(edges, axis=1)
+    edges_uniq, edge_counts = np.unique(edges, axis=0, return_counts=True)
+
+    # mesh is closed if each edge exists twice
+    return edges_uniq[edge_counts != 2]
+
+
+def fix_trimesh_orientation(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
+    """Check if all faces are oriented outwards. Fix the ones that are not, and return an
+    array of properly oriented faces.
+
+    Parameters
+    ----------
+    vertices: np.ndarray, shape (n,3)
+        Vertices of the mesh
+
+    faces: np.ndarray, shape (n,3), dtype int
+        Triples of indices
+
+    Returns
+    -------
+    faces: np.ndarray, shape (n,3), dtype int, or faces and 1D array of triples
+        Fixed faces
+    """
+    # use first triangle as a seed, this one needs to be oriented via inside check
+    # compute facet orientation (normalized)
+    inwards_mask = get_inwards_mask(vertices, faces)
+    new_faces = faces.copy()
+    new_faces[inwards_mask] = new_faces[inwards_mask][:, [0, 2, 1]]
+    return new_faces
+
+
+def is_facet_inwards(face, faces):
+    """Return boolean whether facet is pointing inwards, via ray tracing"""
+    v1 = face[0] - face[1]
+    v2 = face[1] - face[2]
+    orient = np.cross(v1, v2)
+    orient /= np.linalg.norm(orient)  # for single facet numpy is fine
+
+    # create a check point by displacing the facet center in facet orientation direction
+    eps = 1e-5  # unfortunately this must be quite a 'large' number :(
+    check_point = face.mean(axis=0) + orient * eps
+
+    # find out if first point is inwards
+    return mask_inside_trimesh(np.array([check_point]), faces)[0]
+
+
+def get_inwards_mask(
+    vertices: np.ndarray,
+    triangles: np.ndarray,
+) -> np.ndarray:
+    """Return a boolean mask of normals from triangles.
+    True -> Inwards, False -> Outwards.
+    This function does not check if mesh is open, and if it is, it may deliver
+    inconsistent results silently.
+
+    Parameters
+    ----------
+    vertices: np.ndarray, shape (n,3)
+        Vertices of the mesh
+
+    triangles: np.ndarray, shape (n,3), dtype int
+        Triples of indices
+
+    Returns
+    -------
+    boolean mask : ndarray, shape (n,), dtype bool
+        Boolean mask of inwards orientations from provided triangles
+    """
+
+    msh = vertices[triangles]
+    mask = np.full(len(triangles), False)
+    indices = list(range(len(triangles)))
+
+    # incrementally add triangles sharing at least a common edge by looping among left over
+    # triangles. If next triangle with common edge is reversed, flip it.
+    any_connected = False
+    while indices:
+        if not any_connected:
+            free_edges = set()
+            is_inwards = is_facet_inwards(msh[indices[0]], msh[indices])
+            mask[indices] = is_inwards
+        for tri_ind in indices:
+            tri = triangles[tri_ind]
+            edges = {(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])}
+            edges_r = {(tri[1], tri[0]), (tri[2], tri[1]), (tri[0], tri[2])}
+            common = free_edges & edges
+            flip = False
+            if not free_edges:
+                common = True
+            elif common:
+                edges = edges_r
+                flip = True
+            else:
+                common = free_edges & edges_r
+            if common:  # break loop on first common edge found
+                free_edges ^= edges
+                if flip:
+                    mask[tri_ind] = not mask[tri_ind]
+                indices.remove(tri_ind)
+                any_connected = True
+                break
+        else:
+            # if loop reaches the end and does not find any connected edge, while still
+            # having some indices to go through -> mesh is is disconnected. A new seed is
+            # needed and needs to be checked via ray tracing before continuing.
+            any_connected = False
+    return mask
+
+
+def lines_end_in_trimesh(lines: np.ndarray, faces: np.ndarray) -> np.ndarray:
+    """
+    Check if 2-point lines, where the first point lies distinctly outside of a closed
+    triangular mesh (no touch), ends on the inside of that mesh
+
+    If line ends close to a triangle surface it counts as inside (touch).
+    If line passes through triangle edge/corners it counts as intersection.
+
+    Parameters
+    ----------
+    lines: ndarray shape (n,2,3)
+        n line segments defined through respectively 2 (first index) positions with
+        coordinates (x,y,z) (last index). The first point must lie outside of the mesh.
+
+    faces: ndarray, shape (m,3,3)
+        m faces defined through respectively 3 (first index) positions with coordinates
+        (x,y,z) (last index). The faces must define a closed mesh.
+
+    Returns
+    -------
+        np.ndarray shape (n,)
+
+    Note
+    ----
+    Part 1: plane_cross
+    Checks if start and end of lines are on the same or on different sides of the planes
+    defined by the triangular faces. On the way check if end-point touches the plane.
+
+    Part 2: pass_through
+    Makes use of Line-Triangle intersection as described in
+    https://www.iue.tuwien.ac.at/phd/ertl/node114.html
+    to check if the extended line would pass through the triangular facet
+    """
+
+    # Part 1 ---------------------------
+    normals = v_cross(faces[:, 0] - faces[:, 2], faces[:, 1] - faces[:, 2])
+    normals = np.tile(normals, (len(lines), 1, 1))
+
+    l0 = lines[:, 0][:, np.newaxis]  # outside points
+    l1 = lines[:, 1][:, np.newaxis]  # possible inside test-points
+
+    # test-point might coincide with chosen in-plane reference point (chosen faces[:,2] here).
+    # this then leads to bad projection computation
+    # --> choose other reference points (faces[:,1]) in those specific cases
+    ref_pts = np.tile(faces[:, 2], (len(lines), 1, 1))
+    eps = 1e-16  # note: norm square !
+    coincide = v_norm2(l1 - ref_pts) < eps
+    if np.any(coincide):
+        ref_pts2 = np.tile(
+            faces[:, 1], (len(lines), 1, 1)
+        )  # <--inefficient tile !!! only small part needed
+        ref_pts[coincide] = ref_pts2[coincide]
+
+    proj0 = v_norm_proj(l0 - ref_pts, normals)
+    proj1 = v_norm_proj(l1 - ref_pts, normals)
+
+    eps = 1e-7
+    # no need to check proj0 for touch because line init pts are outside
+    plane_touch = np.abs(proj1) < eps
+    # print('plane_touch:')
+    # print(plane_touch)
+
+    plane_cross = np.sign(proj0) != np.sign(proj1)
+    # print('plane_cross:')
+    # print(plane_cross)
+
+    # Part 2 ---------------------------
+    # signed areas (no 0-problem because ss0 is the outside point)
+    a = faces[:, 0] - l0
+    b = faces[:, 1] - l0
+    c = faces[:, 2] - l0
+    d = l1 - l0
+    area1 = v_dot_cross3d(a, b, d)
+    area2 = v_dot_cross3d(b, c, d)
+    area3 = v_dot_cross3d(c, a, d)
+
+    eps = 1e-12
+    pass_through_boundary = (
+        (np.abs(area1) < eps) | (np.abs(area2) < eps) | (np.abs(area3) < eps)
+    )
+    # print('pass_through_boundary:')
+    # print(pass_through_boundary)
+
+    area1 = np.sign(area1)
+    area2 = np.sign(area2)
+    area3 = np.sign(area3)
+    pass_through_inside = (area1 == area2) * (area2 == area3)
+    # print('pass_through_inside:')
+    # print(pass_through_inside)
+
+    pass_through = pass_through_boundary | pass_through_inside
+
+    # Part 3 ---------------------------
+    result_cross = pass_through * plane_cross
+    result_touch = pass_through * plane_touch
+
+    inside1 = np.sum(result_cross, axis=1) % 2 != 0
+    inside2 = np.any(result_touch, axis=1)
+
+    return inside1 | inside2
+
+
+def segments_intersect_facets(segments, facets, eps=1e-6):
+    """Pair-wise detect if set of segments intersect set of facets.
+
+    Parameters
+    -----------
+    segments: np.ndarray, shape (n,3,3)
+        Set of segments.
+
+    facets: np.ndarray, shape (n,3,3)
+        Set of facets.
+
+    eps: float
+        Point to point tolerance detection. Must be strictly positive,
+        otherwise some triangles may be detected as intersecting themselves.
+    """
+    if eps <= 0:  # pragma: no cover
+        msg = "eps must be strictly positive"
+        raise ValueError(msg)
+
+    s, t = segments.swapaxes(0, 1), facets.swapaxes(0, 1)
+
+    # compute the normals to each triangle
+    normals = np.cross(t[2] - t[0], t[2] - t[1])
+    normals /= np.linalg.norm(normals, axis=1, keepdims=True)
+
+    # get sign of each segment endpoint, if the sign changes then we know this
+    # segment crosses the plane which contains a triangle. If the value is zero
+    # the endpoint of the segment lies on the plane.
+    g1 = np.sum(normals * (s[0] - t[2]), axis=1)
+    g2 = np.sum(normals * (s[1] - t[2]), axis=1)
+
+    # determine segments which cross the plane of a triangle.
+    #  -> 1 if the sign of the end points of s is
+    # different AND one of end points of s is not a vertex of t
+    cross = (np.sign(g1) != np.sign(g2)) * (np.abs(g1) > eps) * (np.abs(g2) > eps)
+
+    v = []  # get signed volumes
+    for i, j in zip((0, 1, 2), (1, 2, 0), strict=False):
+        sv = np.sum((t[i] - s[1]) * np.cross(t[j] - s[1], s[0] - s[1]), axis=1)
+        v.append(np.sign(sv))
+
+    # same volume if s and t have same sign in v0, v1 and v2
+    same_volume = np.logical_and((v[0] == v[1]), (v[1] == v[2]))
+
+    return cross * same_volume
+
+
+def get_intersecting_triangles(vertices, triangles, r=None, r_factor=1.5, eps=1e-6):
+    """Return intersecting triangles indices from a triangular mesh described
+    by vertices and triangles indices.
+
+    Parameters
+    ----------
+    vertices: np.ndarray, shape (n,3)
+        Vertices/points of the mesh.
+
+    triangles: np.ndarray, shape (n,3), dtype int
+        Triples of vertices indices that build each triangle of the mesh.
+
+    r: float or None
+        The radius of the ball-point query for the k-d tree. If None:
+        r=max_distance_between_center_and_vertices*2
+
+    r_factor: float
+        The factor by which to multiply the radius `r` of the ball-point query.
+        Note that increasing this value will drastically augment computation
+        time.
+
+    eps: float
+        Point to point tolerance detection. Must be strictly positive,
+        otherwise some triangles may be detected as intersecting themselves.
+    """
+    if r_factor < 1:  # pragma: no cover
+        msg = "r_factor must be greater or equal to 1"
+        raise ValueError(msg)
+
+    vertices = vertices.astype(np.float32)
+    facets = vertices[triangles]
+    centers = np.mean(facets, axis=1)
+
+    if r is None:
+        r = r_factor * np.sqrt(((facets - centers[:, None, :]) ** 2).sum(-1)).max()
+
+    kdtree = scipy.spatial.KDTree(centers)
+    near = kdtree.query_ball_point(centers, r, return_sorted=False, workers=-1)
+    tria1 = np.concatenate(near)
+    tria2 = np.repeat(np.arange(len(near)), [len(n) for n in near])
+    pairs = np.stack([tria1, tria2], axis=1)
+    pairs = pairs[pairs[:, 0] != pairs[:, 1]]  # remove check against itself
+    f1, f2 = facets[pairs[:, 0]], facets[pairs[:, 1]]
+    sums = 0
+    for inds in [[0, 1], [1, 2], [2, 0]]:
+        sums += segments_intersect_facets(f1[:, inds], f2, eps=eps)
+    return np.unique(pairs[sums > 0])
+
+
+def mask_inside_enclosing_box(points: np.ndarray, vertices: np.ndarray) -> np.ndarray:
+    """
+    Quick-check which points lie inside a bounding box of the mesh (defined by vertices).
+    Returns True when inside, False when outside bounding box.
+
+    Parameters
+    ----------
+    points, ndarray, shape (n,3)
+    vertices, ndarray, shape (m,3)
+
+    Returns
+    -------
+    ndarray, boolean, shape (n,)
+    """
+    xmin, ymin, zmin = np.min(vertices, axis=0)
+    xmax, ymax, zmax = np.max(vertices, axis=0)
+    x, y, z = points.T
+
+    eps = 1e-12
+    mx = (x < xmax + eps) & (x > xmin - eps)
+    my = (y < ymax + eps) & (y > ymin - eps)
+    mz = (z < zmax + eps) & (z > zmin - eps)
+
+    return mx & my & mz
+
+
+def mask_inside_trimesh(points: np.ndarray, faces: np.ndarray) -> np.ndarray:
+    """
+    Check which points lie inside of a closed triangular mesh (defined by faces).
+
+    Parameters
+    ----------
+    points, ndarray, shape (n,3)
+    faces, ndarray, shape (m,3,3)
+
+    Returns
+    -------
+    ndarray, shape (n,)
+
+    Note
+    ----
+    Method: ray-tracing.
+    Faces must form a closed mesh for this to work.
+    """
+    vertices = faces.reshape((-1, 3))
+
+    # test-points inside of enclosing box
+    mask_inside = mask_inside_enclosing_box(points, vertices)
+    pts_in_box = points[mask_inside]
+
+    # create test-lines from outside to test-points
+    start_point_outside = np.min(vertices, axis=0) - np.array(
+        [12.0012345, 5.9923456, 6.9932109]
+    )
+    test_lines = np.tile(start_point_outside, (len(pts_in_box), 2, 1))
+    test_lines[:, 1] = pts_in_box
+
+    # check if test-points are inside using ray tracing
+    mask_inside2 = lines_end_in_trimesh(test_lines, faces)
+
+    mask_inside[mask_inside] = mask_inside2
+
+    return mask_inside
+
+
+def BHJM_magnet_trimesh(
+    field: str,
+    observers: np.ndarray,
+    mesh: np.ndarray,
+    polarization: np.ndarray,
+    in_out="auto",
+) -> np.ndarray:
+    """
+    - Compute triangular mesh field from triangle fields.
+    - Closed meshes are assumed (input comes only from TriangularMesh class)
+    - Field computations via publication: Guptasarma: GEOPHYSICS 1999 64:1, 70-74
+    """
+    if field in "BH":
+        if mesh.ndim != 1:  # all vertices objects have same number of children
+            n0, n1, *_ = mesh.shape
+            vertices_tiled = mesh.reshape(-1, 3, 3)
+            observers_tiled = np.repeat(observers, n1, axis=0)
+            polarization_tiled = np.repeat(polarization, n1, axis=0)
+            BHJM = BHJM_triangle(
+                field="B",
+                observers=observers_tiled,
+                vertices=vertices_tiled,
+                polarization=polarization_tiled,
+            )
+            BHJM = BHJM.reshape((n0, n1, 3))
+            BHJM = np.sum(BHJM, axis=1)
+        else:
+            nvs = [f.shape[0] for f in mesh]  # length of vertex set
+            split_indices = np.cumsum(nvs)[:-1]  # remove last to avoid empty split
+            vertices_tiled = np.concatenate([f.reshape((-1, 3, 3)) for f in mesh])
+            observers_tiled = np.repeat(observers, nvs, axis=0)
+            polarization_tiled = np.repeat(polarization, nvs, axis=0)
+            BHJM = BHJM_triangle(
+                field="B",
+                observers=observers_tiled,
+                vertices=vertices_tiled,
+                polarization=polarization_tiled,
+            )
+            b_split = np.split(BHJM, split_indices)
+            BHJM = np.array([np.sum(bh, axis=0) for bh in b_split])
+    else:
+        BHJM = np.zeros_like(observers, dtype=float)
+
+    if field == "H":
+        return BHJM / MU0
+
+    if in_out == "auto":
+        prev_ind = 0
+        # group similar meshes for inside-outside evaluation and adding B
+        for new_ind_item, _ in enumerate(BHJM):
+            new_ind = new_ind_item
+            if (
+                new_ind == len(BHJM) - 1
+                or mesh[new_ind].shape != mesh[prev_ind].shape
+                or not np.all(mesh[new_ind] == mesh[prev_ind])
+            ):
+                if new_ind == len(BHJM) - 1:
+                    new_ind = len(BHJM)
+                mask_inside = mask_inside_trimesh(
+                    observers[prev_ind:new_ind], mesh[prev_ind]
+                )
+                # if inside magnet add polarization vector
+                BHJM[prev_ind:new_ind][mask_inside] += polarization[prev_ind:new_ind][
+                    mask_inside
+                ]
+                prev_ind = new_ind
+    elif in_out == "inside":
+        BHJM += polarization
+
+    if field == "B":
+        return BHJM
+
+    if field == "J":
+        return BHJM
+
+    if field == "M":
+        return BHJM / MU0
+
+    msg = f"`output_field_type` must be one of ('B', 'H', 'M', 'J'), got {field!r}"
+    raise ValueError(msg)  # pragma: no cover
diff --git a/src/magpylib/_src/fields/field_wrap_BH.py b/src/magpylib/_src/fields/field_wrap_BH.py
new file mode 100644
index 000000000..9cbca8562
--- /dev/null
+++ b/src/magpylib/_src/fields/field_wrap_BH.py
@@ -0,0 +1,1278 @@
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-lines
+# pylint: disable=too-many-positional-arguments
+
+"""Field computation structure:
+
+level_core:(field_BH_XXX.py files)
+    - pure vectorized field computations from literature
+    - all computations in source CS
+
+level0a:(BHJM_XX)
+    - distinguish between B, H, J and M
+
+level0b:(BHJM_internal_XX)
+    - connect BHJM-level to level1
+
+level1(getBH_level1):
+    - apply transformation to global CS
+    - select correct level0 src_type computation
+    - input dict, no input checks !
+
+level2(getBHv_level2):  <--- DIRECT ACCESS TO FIELD COMPUTATION FORMULAS, INPUT = DICT OF ARRAYS
+    - input dict checks (unknowns)
+    - secure user inputs
+    - check input for mandatory information
+    - set missing input variables to default values
+    - tile 1D inputs
+
+level2(getBH_level2):   <--- COMPUTE FIELDS FROM SOURCES
+    - input dict checks (unknowns)
+    - secure user inputs
+    - group similar sources for combined computation
+    - generate vector input format for getBH_level1
+    - adjust Bfield output format to (pos_obs, path, sources) input format
+
+level3(getB, getH, getB_dict, getH_dict): <--- USER INTERFACE
+    - docstrings
+    - separated B and H
+    - transform input into dict for level2
+
+level4(src.getB, src.getH):       <--- USER INTERFACE
+    - docstrings
+    - calling level3 getB, getH directly from sources
+
+level3(getBH_from_sensor):
+    - adjust output format to (senors, path, sources) input format
+
+level4(getB_from_sensor, getH_from_sensor): <--- USER INTERFACE
+
+level5(sens.getB, sens.getH): <--- USER INTERFACE
+"""
+
+from __future__ import annotations
+
+import numbers
+import warnings
+from collections.abc import Callable
+from itertools import product
+
+import numpy as np
+from scipy.spatial.transform import Rotation as R
+
+from magpylib._src.exceptions import MagpylibBadUserInput, MagpylibMissingInput
+from magpylib._src.input_checks import (
+    check_dimensions,
+    check_excitations,
+    check_format_input_observers,
+    check_format_pixel_agg,
+    check_getBH_output_type,
+)
+from magpylib._src.utility import (
+    check_static_sensor_orient,
+    format_obj_input,
+    format_src_inputs,
+    get_registered_sources,
+    has_parameter,
+)
+
+
+def tile_group_property(group: list, n_pp: int, prop_name: str):
+    """tile up group property"""
+    out = [getattr(src, prop_name) for src in group]
+    if not np.isscalar(out[0]) and any(o.shape != out[0].shape for o in out):
+        out = np.asarray(out, dtype="object")
+    else:
+        out = np.array(out)
+    return np.repeat(out, n_pp, axis=0)
+
+
+def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict:
+    """create dictionaries for level1 input"""
+    # pylint: disable=protected-access
+    # pylint: disable=too-many-return-statements
+
+    # tile up basic attributes that all sources have
+    # position
+    poss = np.array([src._position for src in group])
+    posv = np.tile(poss, n_pix).reshape((-1, 3))
+
+    # orientation
+    rots = np.array([src._orientation.as_quat() for src in group])
+    rotv = np.tile(rots, n_pix).reshape((-1, 4))
+    rotobj = R.from_quat(rotv)
+
+    # pos_obs
+    posov = np.tile(poso, (len(group), 1))
+
+    # determine which group we are dealing with and tile up properties
+
+    kwargs = {
+        "position": posv,
+        "observers": posov,
+        "orientation": rotobj,
+    }
+
+    src_props = group[0]._field_func_kwargs_ndim
+
+    for prop in src_props:
+        if hasattr(group[0], prop) and prop not in (
+            "position",
+            "orientation",
+            "observers",
+        ):
+            kwargs[prop] = tile_group_property(group, n_pp, prop)
+
+    return kwargs
+
+
+def getBH_level1(
+    *,
+    field_func: Callable,
+    field: str,
+    position: np.ndarray,
+    orientation: np.ndarray,
+    observers: np.ndarray,
+    **kwargs: dict,
+) -> np.ndarray:
+    """Vectorized field computation
+
+    - applies spatial transformations global CS <-> source CS
+    - selects the correct Bfield_XXX function from input
+
+    Args
+    ----
+    kwargs: dict of shape (N,x) input vectors that describes the computation.
+
+    Returns
+    -------
+    field: ndarray, shape (N,3)
+
+    """
+
+    # transform obs_pos into source CS
+    pos_rel_rot = orientation.apply(observers - position, inverse=True)
+
+    # filter arguments
+    if not has_parameter(field_func, "in_out"):  # in_out passed only to magnets
+        kwargs.pop("in_out", None)
+
+    # compute field
+    BH = field_func(field=field, observers=pos_rel_rot, **kwargs)
+
+    # transform field back into global CS
+    if BH is not None:  # catch non-implemented field_func a level above
+        BH = orientation.apply(BH)
+
+    return BH
+
+
+def getBH_level2(
+    sources, observers, *, field, sumup, squeeze, pixel_agg, output, in_out, **kwargs
+) -> np.ndarray:
+    """Compute field for given sources and observers.
+    Info:
+    -----
+    - generates a 1D list of sources (collections flattened) and a 1D list of sensors from input
+    - tile all paths of static (path_length=1) objects
+    - combine all sensor pixel positions for joint evaluation
+    - group similar source types for joint evaluation
+    - compute field and store in allocated array
+    - rearrange the array in the shape squeeze((L, M, K, N1, N2, ...,3))
+    """
+    # pylint: disable=protected-access
+    # pylint: disable=too-many-branches
+    # pylint: disable=too-many-statements
+    # pylint: disable=import-outside-toplevel
+
+    from magpylib._src.obj_classes.class_Collection import Collection
+    from magpylib._src.obj_classes.class_magnet_TriangularMesh import TriangularMesh
+
+    # CHECK AND FORMAT INPUT ---------------------------------------------------
+    if isinstance(sources, str):
+        return getBH_dict_level2(
+            source_type=sources,
+            observers=observers,
+            field=field,
+            squeeze=squeeze,
+            in_out=in_out,
+            **kwargs,
+        )
+
+    # bad user inputs mixing getBH_dict kwargs with object oriented interface
+    if kwargs:
+        msg = (
+            f"Keyword arguments {tuple(kwargs.keys())} are only allowed when the source "
+            "is defined by a string (e.g. sources='Cylinder')"
+        )
+        raise MagpylibBadUserInput(msg)
+
+    # format sources input:
+    #   input: allow only one bare src object or a 1D list/tuple of src and col
+    #   out: sources = ordered list of sources
+    #   out: src_list = ordered list of sources with flattened collections
+    sources, src_list = format_src_inputs(sources)
+
+    # test if all source dimensions and excitations are initialized
+    check_dimensions(src_list)
+    check_excitations(src_list)
+
+    # make sure that given in_out there is a Tetrahedron class or a TriangularMesh
+    #   class in sources. Else throw a warning
+    if in_out != "auto":
+        from magpylib._src.obj_classes.class_magnet_Tetrahedron import Tetrahedron
+
+        if not any(isinstance(src, Tetrahedron | TriangularMesh) for src in src_list):
+            warnings.warn(
+                "Argument `in_out` for field computation was set, but is ignored"
+                " in the computation. `in_out` has an effect only for magnet classes"
+                " Tetrahedron and TriangularMesh.",
+                UserWarning,
+                stacklevel=2,
+            )
+
+    # make sure that TriangularMesh sources have a closed mesh when getB is called - warn if not
+    if field == "B":
+        for src in src_list:
+            if isinstance(src, TriangularMesh):
+                # unchecked mesh status - may be open
+                if src.status_open is None:
+                    warnings.warn(
+                        f"Unchecked mesh status of {src} detected before B-field computation. "
+                        "An open mesh may return bad results.",
+                        stacklevel=2,
+                    )
+                elif src.status_open:  # mesh is open
+                    warnings.warn(
+                        f"Open mesh of {src} detected before B-field computation. "
+                        "An open mesh may return bad results.",
+                        stacklevel=2,
+                    )
+
+    # format observers input:
+    #   allow only bare sensor, collection, pos_vec or list thereof
+    #   transform input into an ordered list of sensors (pos_vec->pixel)
+    #   check if all pixel shapes are similar - or else if pixel_agg is given
+
+    pixel_agg_func = check_format_pixel_agg(pixel_agg)
+    sensors, pix_shapes = check_format_input_observers(observers, pixel_agg)
+    pix_nums = [
+        int(np.prod(ps[:-1])) for ps in pix_shapes
+    ]  # number of pixel for each sensor
+    pix_inds = np.cumsum([0, *pix_nums])  # cumulative indices of pixel for each sensor
+    pix_all_same = len(set(pix_shapes)) == 1
+
+    # check which sensors have unit rotation
+    #   so that they dont have to be rotated back later (performance issue)
+    #   this check is made now when sensor paths are not yet tiled.
+    unitQ = np.array([0, 0, 0, 1.0])
+    unrotated_sensors = [
+        all(all(r == unitQ) for r in sens._orientation.as_quat()) for sens in sensors
+    ]
+
+    # check which sensors have a static orientation
+    #   either static sensor or translation path
+    #   later such sensors require less back-rotation effort (performance issue)
+    static_sensor_rot = check_static_sensor_orient(sensors)
+
+    # some important quantities -------------------------------------------------
+    obj_list = set(src_list + sensors)  # unique obj entries only !!!
+    num_of_sources = len(sources)
+    num_of_src_list = len(src_list)
+    num_of_sensors = len(sensors)
+
+    # tile up paths -------------------------------------------------------------
+    #   all obj paths that are shorter than max-length are filled up with the last
+    #   position/orientation of the object (static paths)
+    path_lengths = [len(obj._position) for obj in obj_list]
+    max_path_len = max(path_lengths)
+
+    # objects to tile up and reset below
+    mask_reset = [max_path_len != pl for pl in path_lengths]
+    reset_obj = [obj for obj, mask in zip(obj_list, mask_reset, strict=False) if mask]
+    reset_obj_m0 = [
+        pl for pl, mask in zip(path_lengths, mask_reset, strict=False) if mask
+    ]
+
+    if max_path_len > 1:
+        for obj, m0 in zip(reset_obj, reset_obj_m0, strict=False):
+            # length to be tiled
+            m_tile = max_path_len - m0
+            # tile up position
+            tile_pos = np.tile(obj._position[-1], (m_tile, 1))
+            obj._position = np.concatenate((obj._position, tile_pos))
+            # tile up orientation
+            tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile, 1))
+            # FUTURE use Rotation.concatenate() requires scipy>=1.8 and python 3.8
+            tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient))
+            obj._orientation = R.from_quat(tile_orient)
+
+    # combine information form all sensors to generate pos_obs with-------------
+    #   shape (m * concat all sens flat pixel, 3)
+    #   allows sensors with different pixel shapes <- relevant?
+    poso = [
+        [
+            (
+                np.array([[0, 0, 0]])
+                if sens.pixel is None
+                else r.apply(sens.pixel.reshape(-1, 3))
+            )
+            + p
+            for r, p in zip(sens._orientation, sens._position, strict=False)
+        ]
+        for sens in sensors
+    ]
+    poso = np.concatenate(poso, axis=1).reshape(-1, 3)
+    n_pp = len(poso)
+    n_pix = int(n_pp / max_path_len)
+
+    # group similar source types----------------------------------------------
+    field_func_groups = {}
+    for ind, src in enumerate(src_list):
+        group_key = src.field_func
+        if group_key is None:
+            msg = (
+                f"Cannot compute {field}-field because "
+                f"`field_func` of {src} has undefined {field}-field computation."
+            )
+            raise MagpylibMissingInput(msg)
+        if group_key not in field_func_groups:
+            field_func_groups[group_key] = {
+                "sources": [],
+                "order": [],
+            }
+        field_func_groups[group_key]["sources"].append(src)
+        field_func_groups[group_key]["order"].append(ind)
+
+    # evaluate each group in one vectorized step -------------------------------
+    B = np.empty((num_of_src_list, max_path_len, n_pix, 3))  # allocate B
+    for field_func, group in field_func_groups.items():
+        lg = len(group["sources"])
+        gr = group["sources"]
+        src_dict = get_src_dict(gr, n_pix, n_pp, poso)  # compute array dict for level1
+        # compute field
+        B_group = getBH_level1(
+            field_func=field_func, field=field, in_out=in_out, **src_dict
+        )
+        if B_group is None:
+            msg = (
+                f"Cannot compute {field}-field because "
+                f"`field_func` {field_func} has undefined {field}-field computation."
+            )
+            raise MagpylibMissingInput(msg)
+        B_group = B_group.reshape(
+            (lg, max_path_len, n_pix, 3)
+        )  # reshape (2% slower for large arrays)
+        for gr_ind in range(lg):  # put into dedicated positions in B
+            B[group["order"][gr_ind]] = B_group[gr_ind]
+
+    # reshape output ----------------------------------------------------------------
+    # rearrange B when there is at least one Collection with more than one source
+    if num_of_src_list > num_of_sources:
+        for src_ind, src in enumerate(sources):
+            if isinstance(src, Collection):
+                col_len = len(format_obj_input(src, allow="sources"))
+                # set B[i] to sum of slice
+                B[src_ind] = np.sum(B[src_ind : src_ind + col_len], axis=0)
+                B = np.delete(
+                    B, np.s_[src_ind + 1 : src_ind + col_len], 0
+                )  # delete remaining part of slice
+
+    # apply sensor rotations (after summation over collections to reduce rot.apply operations)
+    for sens_ind, sens in enumerate(sensors):  # cycle through all sensors
+        pix_slice = slice(pix_inds[sens_ind], pix_inds[sens_ind + 1])
+        if not unrotated_sensors[sens_ind]:  # apply operations only to rotated sensors
+            # select part where rot is applied
+            Bpart = B[:, :, pix_slice]
+            # change shape to (P,3) for rot package
+            Bpart_orig_shape = Bpart.shape
+            Bpart_flat = np.reshape(Bpart, (-1, 3))
+            # apply sensor rotation
+            if static_sensor_rot[sens_ind]:  # special case: same rotation along path
+                sens_orient = sens._orientation[0]
+            else:
+                sens_orient = R.from_quat(
+                    np.tile(  # tile for each source from list
+                        np.repeat(  # same orientation path index for all indices
+                            sens._orientation.as_quat(), pix_nums[sens_ind], axis=0
+                        ),
+                        (num_of_sources, 1),
+                    )
+                )
+            Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat)
+            # overwrite Bpart in B
+            B[:, :, pix_slice] = np.reshape(Bpart_flat_rot, Bpart_orig_shape)
+        if sens.handedness == "left":
+            B[..., pix_slice, 0] *= -1
+
+    # rearrange sensor-pixel shape
+    if pix_all_same:
+        B = B.reshape((num_of_sources, max_path_len, num_of_sensors, *pix_shapes[0]))
+        # aggregate pixel values
+        if pixel_agg is not None:
+            B = pixel_agg_func(B, axis=tuple(range(3 - B.ndim, -1)))
+    else:  # pixel_agg is not None when pix_all_same, checked with
+        Bsplit = np.split(B, pix_inds[1:-1], axis=2)
+        Bagg = [np.expand_dims(pixel_agg_func(b, axis=2), axis=2) for b in Bsplit]
+        B = np.concatenate(Bagg, axis=2)
+
+    # reset tiled objects
+    for obj, m0 in zip(reset_obj, reset_obj_m0, strict=False):
+        obj._position = obj._position[:m0]
+        obj._orientation = obj._orientation[:m0]
+
+    # sumup over sources
+    if sumup:
+        B = np.sum(B, axis=0, keepdims=True)
+
+    output = check_getBH_output_type(output)
+
+    if output == "dataframe":
+        # pylint: disable=import-outside-toplevel
+        # pylint: disable=no-member
+        import pandas as pd
+
+        if sumup and len(sources) > 1:
+            src_ids = [f"sumup ({len(sources)})"]
+        else:
+            src_ids = [s.style.label if s.style.label else f"{s}" for s in sources]
+        sens_ids = [s.style.label if s.style.label else f"{s}" for s in sensors]
+        num_of_pixels = np.prod(pix_shapes[0][:-1]) if pixel_agg is None else 1
+        df_field = pd.DataFrame(
+            data=product(src_ids, range(max_path_len), sens_ids, range(num_of_pixels)),
+            columns=["source", "path", "sensor", "pixel"],
+        )
+        df_field[[field + k for k in "xyz"]] = B.reshape(-1, 3)
+        return df_field
+
+    # reduce all size-1 levels
+    if squeeze:
+        B = np.squeeze(B)
+    elif pixel_agg is not None:
+        # add missing dimension since `pixel_agg` reduces pixel
+        # dimensions to zero. Only needed if `squeeze is False``
+        B = np.expand_dims(B, axis=-2)
+
+    return B
+
+
+def getBH_dict_level2(
+    source_type,
+    observers,
+    *,
+    field: str,
+    position=(0, 0, 0),
+    orientation=None,
+    squeeze=True,
+    in_out="auto",
+    **kwargs: dict,
+) -> np.ndarray:
+    """Functional interface access to vectorized computation
+
+    Parameters
+    ----------
+    kwargs: dict that describes the computation.
+
+    Returns
+    -------
+    field: ndarray, shape (N,3), field at obs_pos in tesla or A/m
+
+    Info
+    ----
+    - check inputs
+
+    - secures input types (list/tuple -> ndarray)
+    - test if mandatory inputs are there
+    - sets default input variables (e.g. pos, rot) if missing
+    - tiles 1D inputs vectors to correct dimension
+    """
+    # pylint: disable=protected-access
+    # pylint: disable=too-many-branches
+
+    # generate dict of secured inputs for auto-tiling ---------------
+    #  entries in this dict will be tested for input length, and then
+    #  be automatically tiled up and stored back into kwargs for calling
+    #  getBH_level1().
+    #  To allow different input dimensions, the ndim argument is also given
+    #  which tells the program which dimension it should tile up.
+
+    # pylint: disable=import-outside-toplevel
+    if orientation is None:
+        orientation = R.identity()
+    try:
+        source_classes = get_registered_sources()
+        field_func = source_classes[source_type]._field_func
+        field_func_kwargs_ndim = {"position": 2, "orientation": 2, "observers": 2}
+        field_func_kwargs_ndim.update(
+            source_classes[source_type]._field_func_kwargs_ndim
+        )
+    except KeyError as err:
+        msg = (
+            f"Input parameter `sources` must be one of {list(source_classes)}"
+            " when using the functional interface."
+        )
+        raise MagpylibBadUserInput(msg) from err
+
+    kwargs["observers"] = observers
+    kwargs["position"] = position
+
+    # change orientation to Rotation numpy array for tiling
+    kwargs["orientation"] = orientation.as_quat()
+
+    # evaluation vector lengths
+    vec_lengths = {}
+    ragged_seq = {}
+    for key, val_item in kwargs.items():
+        val = val_item
+        try:
+            if (
+                not isinstance(val, numbers.Number)
+                and not isinstance(val[0], numbers.Number)
+                and any(len(o) != len(val[0]) for o in val)
+            ):
+                ragged_seq[key] = True
+                val = np.array([np.array(v, dtype=float) for v in val], dtype="object")
+            else:
+                ragged_seq[key] = False
+                val = np.array(val, dtype=float)
+        except TypeError as err:
+            msg = f"{key} input must be array-like.\nInstead received {val}"
+            raise MagpylibBadUserInput(msg) from err
+        expected_dim = field_func_kwargs_ndim.get(key, 1)
+        if val.ndim == expected_dim or ragged_seq[key]:
+            if len(val) == 1:
+                val = np.squeeze(val)
+            else:
+                vec_lengths[key] = len(val)
+
+        kwargs[key] = val
+
+    if len(set(vec_lengths.values())) > 1:
+        msg = (
+            "Input array lengths must be 1 or of a similar length.\n"
+            f"Instead received lengths {vec_lengths}"
+        )
+        raise MagpylibBadUserInput(msg)
+    vec_len = max(vec_lengths.values(), default=1)
+    # tile 1D inputs and replace original values in kwargs
+    for key, val in kwargs.items():
+        expected_dim = field_func_kwargs_ndim.get(key, 1)
+        if val.ndim < expected_dim and not ragged_seq[key]:
+            kwargs[key] = np.tile(val, (vec_len, *[1] * (expected_dim - 1)))
+
+    # change orientation back to Rotation object
+    kwargs["orientation"] = R.from_quat(kwargs["orientation"])
+
+    # compute and return B
+    B = getBH_level1(field=field, field_func=field_func, in_out=in_out, **kwargs)
+
+    if B is not None and squeeze:
+        return np.squeeze(B)
+    return B
+
+
+def getB(
+    sources=None,
+    observers=None,
+    sumup=False,
+    squeeze=True,
+    pixel_agg=None,
+    output="ndarray",
+    in_out="auto",
+    **kwargs,
+):
+    """Compute B-field in units of T for given sources and observers.
+
+    Field implementations can be directly accessed (avoiding the object oriented
+    Magpylib interface) by providing a string input `sources=source_type`, array_like
+    positions as `observers` input, and all other necessary input parameters (see below)
+    as kwargs.
+
+    Parameters
+    ----------
+    sources: source and collection objects or 1D list thereof
+        Sources that generate the magnetic field. Can be a single source (or collection)
+        or a 1D list of l sources and/or collection objects.
+
+        Functional interface: input must be one of (`Cuboid`, `Cylinder`, `CylinderSegment`,
+        `Sphere`, `Dipole`, `Circle` or `Polyline`).
+
+    observers: array_like or (list of) `Sensor` objects
+        Can be array_like positions of shape (n1, n2, ..., 3) where the field
+        should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+        of such sensor objects (must all have similar pixel shapes). All positions
+        are given in units of m.
+
+        Functional interface: Input must be array_like with shape (3,) or (n,3) corresponding
+        positions to observer positions in units of m.
+
+    sumup: bool, default=`False`
+        If `True`, the fields of all sources are summed up.
+
+    squeeze: bool, default=`True`
+        If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+        a single sensor or only a single source) are eliminated.
+
+    pixel_agg: str, default=`None`
+        Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+        which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+        With this option, observers input with different (pixel) shapes is allowed.
+
+    output: str, default=`'ndarray'`
+        Output type, which must be one of (`'ndarray'`, `'dataframe'`). By default a
+        `numpy.ndarray` object is returned. If `'dataframe'` is chosen, a `pandas.DataFrame`
+        object is returned (the Pandas library must be installed).
+
+    in_out: {'auto', 'inside', 'outside'}
+        This parameter only applies for magnet bodies. It specifies the location of the
+        observers relative to the magnet body, affecting the calculation of the magnetic field.
+        The options are:
+        - 'auto': The location (inside or outside the cuboid) is determined automatically for
+        each observer.
+        - 'inside': All observers are considered to be inside the cuboid; use this for
+            performance optimization if applicable.
+        - 'outside': All observers are considered to be outside the cuboid; use this for
+            performance optimization if applicable.
+        Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+        locations is unknown.
+
+    See Also
+    --------
+    *Functional interface
+
+    position: array_like, shape (3,) or (n,3), default=`(0,0,0)`
+        Source position(s) in the global coordinates in units of m.
+
+    orientation: scipy `Rotation` object with length 1 or n, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation.
+
+    polarization: array_like, shape (3,) or (n,3)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`, `Sphere`,
+        `Tetrahedron`, `Triangle`, `TriangularMesh`)!
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,) or (n,3)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`, `Sphere`,
+        `Tetrahedron`, `Triangle`, `TriangularMesh`)!
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    moment: array_like, shape (3) or (n,3), unit A·m²
+        Only source_type == `Dipole`!
+        Magnetic dipole moment(s) in units of A·m² given in the local object coordinates
+        (rotates with object). For homogeneous magnets the relation moment=magnetization*volume
+        holds.
+
+    current: array_like, shape (n,)
+        Only source_type == `Circle` or `Polyline`!
+        Electrical current in units of A.
+
+    dimension: array_like, shape (x,) or (n,x)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`)!
+        Magnet dimension input in units of m and deg. Dimension format x of sources is similar
+        as in object oriented interface.
+
+    diameter: array_like, shape (n,)
+        Only source_type == `Sphere` or `Circle`!
+        Diameter of source in units of m.
+
+    segment_start: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        Start positions of line current segments in units of m.
+
+    segment_end: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        End positions of line current segments in units of m.
+
+    Returns
+    -------
+    B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+        B-field at each path position (index m) for each sensor (index k) and each sensor pixel
+        position (indices n1, n2, ...) in units of T. Sensor pixel positions are equivalent
+        to simple observer positions. Paths of objects that are shorter than index m are
+        considered as static beyond their end.
+
+    Functional interface: ndarray, shape (n,3)
+        B-field for every parameter set in units of T.
+
+    Notes
+    -----
+    This function automatically joins all sensor and position inputs together and groups
+    similar sources for optimal vectorization of the computation. For maximal performance
+    call this function as little as possible and avoid using it in loops.
+
+    Examples
+    --------
+    In this example we compute the B-field in T of a spherical magnet and a current
+    loop at the observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src1 = magpy.current.Circle(current=100, diameter=.002)
+    >>> src2 = magpy.magnet.Sphere(polarization=(0,0,.1), diameter=.001)
+    >>> B = magpy.getB([src1, src2], (.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[ 6.054e-06  6.054e-06  2.357e-08]
+     [ 8.019e-07  8.019e-07 -9.056e-23]]
+
+    We can also use sensor objects as observers input:
+
+    >>> sens1 = magpy.Sensor(position=(.01,.01,.01))
+    >>> sens2 = sens1.copy(position=(.01,.01,-.01))
+    >>> B = magpy.getB([src1, src2], [sens1, sens2])
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[[ 6.054e-06  6.054e-06  2.357e-08]
+      [-6.054e-06 -6.054e-06  2.357e-08]]
+    <BLANKLINE>
+     [[ 8.019e-07  8.019e-07 -9.056e-23]
+      [-8.019e-07 -8.019e-07 -9.056e-23]]]
+
+    Through the functional interface we can compute the same fields for the loop as:
+
+    >>> obs = [(.01,.01,.01), (.01,.01,-.01)]
+    >>> B = magpy.getB('Circle', obs, current=100, diameter=.002)
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[ 6.054e-06  6.054e-06  2.357e-08]
+     [-6.054e-06 -6.054e-06  2.357e-08]]
+
+    But also for a set of four completely different instances:
+
+    >>> B = magpy.getB(
+    ...     'Circle',
+    ...     observers=((.01,.01,.01), (.01,.01,-.01), (.01,.02,.03), (.02,.02,.02)),
+    ...     current=(11, 22, 33, 44),
+    ...     diameter=(.001, .002, .003, .004),
+    ...     position=((0,0,0), (0,0,.01), (0,0,.02), (0,0,.03)),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[ 1.663e-07  1.663e-07  1.617e-10]
+     [-4.695e-07 -4.695e-07  4.707e-07]
+     [ 7.970e-07  1.594e-06 -7.913e-07]
+     [-1.374e-06 -1.374e-06 -1.366e-06]]
+    """
+    return getBH_level2(
+        sources,
+        observers,
+        field="B",
+        sumup=sumup,
+        squeeze=squeeze,
+        pixel_agg=pixel_agg,
+        output=output,
+        in_out=in_out,
+        **kwargs,
+    )
+
+
+def getH(
+    sources=None,
+    observers=None,
+    sumup=False,
+    squeeze=True,
+    pixel_agg=None,
+    output="ndarray",
+    in_out="auto",
+    **kwargs,
+):
+    """Compute H-field in units of A/m for given sources and observers.
+
+    Field implementations can be directly accessed (avoiding the object oriented
+    Magpylib interface) by providing a string input `sources=source_type`, array_like
+    positions as `observers` input, and all other necessary input parameters (see below)
+    as kwargs.
+
+    Parameters
+    ----------
+    sources: source and collection objects or 1D list thereof
+        Sources that generate the magnetic field. Can be a single source (or collection)
+        or a 1D list of l sources and/or collection objects.
+
+        Functional interface: input must be one of (`Cuboid`, `Cylinder`, `CylinderSegment`,
+        `Sphere`, `Dipole`, `Circle` or `Polyline`).
+
+    observers: array_like or (list of) `Sensor` objects
+        Can be array_like positions of shape (n1, n2, ..., 3) where the field
+        should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+        of such sensor objects (must all have similar pixel shapes). All positions
+        are given in units of m.
+
+        Functional interface: Input must be array_like with shape (3,) or (n,3) corresponding
+        positions to observer positions in units of m.
+
+    sumup: bool, default=`False`
+        If `True`, the fields of all sources are summed up.
+
+    squeeze: bool, default=`True`
+        If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+        a single sensor or only a single source) are eliminated.
+
+    pixel_agg: str, default=`None`
+        Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+        which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+        With this option, observers input with different (pixel) shapes is allowed.
+
+    output: str, default=`'ndarray'`
+        Output type, which must be one of (`'ndarray'`, `'dataframe'`). By default a
+        `numpy.ndarray` object is returned. If `'dataframe'` is chosen, a `pandas.DataFrame`
+        object is returned (the Pandas library must be installed).
+
+    in_out: {'auto', 'inside', 'outside'}
+        This parameter only applies for magnet bodies. It specifies the location of the
+        observers relative to the magnet body, affecting the calculation of the magnetic field.
+        The options are:
+        - 'auto': The location (inside or outside the cuboid) is determined automatically for
+        each observer.
+        - 'inside': All observers are considered to be inside the cuboid; use this for
+            performance optimization if applicable.
+        - 'outside': All observers are considered to be outside the cuboid; use this for
+            performance optimization if applicable.
+        Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+        locations is unknown.
+
+    See Also
+    --------
+    *Functional interface
+
+    position: array_like, shape (3,) or (n,3), default=`(0,0,0)`
+        Source position(s) in the global coordinates in units of m.
+
+    orientation: scipy `Rotation` object with length 1 or n, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation.
+
+    magnetization: array_like, shape (3,) or (n,3)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`, `Sphere`)!
+        Magnetization vector(s) (mu0*M, remanence field) in units of A/m given in
+        the local object coordinates (rotates with object).
+
+    moment: array_like, shape (3) or (n,3), unit A·m²
+        Only source_type == `Dipole`!
+        Magnetic dipole moment(s) in units of A·m² given in the local object coordinates
+        (rotates with object). For homogeneous magnets the relation moment=magnetization*volume
+        holds.
+
+    current: array_like, shape (n,)
+        Only source_type == `Circle` or `Polyline`!
+        Electrical current in units of A.
+
+    dimension: array_like, shape (x,) or (n,x)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`)!
+        Magnet dimension input in units of m and deg. Dimension format x of sources is similar
+        as in object oriented interface.
+
+    diameter: array_like, shape (n,)
+        Only source_type == `Sphere` or `Circle`!
+        Diameter of source in units of m.
+
+    segment_start: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        Start positions of line current segments in units of m.
+
+    segment_end: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        End positions of line current segments in units of m.
+
+    Returns
+    -------
+    H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+        H-field at each path position (index m) for each sensor (index k) and each sensor pixel
+        position (indices n1, n2, ...) in units of A/m. Sensor pixel positions are equivalent
+        to simple observer positions. Paths of objects that are shorter than index m are
+        considered as static beyond their end.
+
+    Functional interface: ndarray, shape (n,3)
+        H-field for every parameter set in units of A/m.
+
+    Notes
+    -----
+    This function automatically joins all sensor and position inputs together and groups
+    similar sources for optimal vectorization of the computation. For maximal performance
+    call this function as little as possible and avoid using it in loops.
+
+    Examples
+    --------
+    In this example we compute the H-field in A/m of a spherical magnet and a current loop
+    at the observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src1 = magpy.current.Circle(current=100, diameter=.002)
+    >>> src2 = magpy.magnet.Sphere(polarization=(0,0,.1), diameter=.001)
+    >>> H = magpy.getH([src1, src2], (.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...    print(H)
+    [[ 4.818e+00  4.818e+00  1.875e-02]
+     [ 6.381e-01  6.381e-01 -7.207e-17]]
+
+    We can also use sensor objects as observers input:
+
+    >>> sens1 = magpy.Sensor(position=(.01,.01,.01))
+    >>> sens2 = sens1.copy(position=(.01,.01,-.01))
+    >>> H = magpy.getH([src1, src2], [sens1, sens2])
+    >>> with np.printoptions(precision=3):
+    ...    print(H)
+    [[[ 4.818e+00  4.818e+00  1.875e-02]
+      [-4.818e+00 -4.818e+00  1.875e-02]]
+    <BLANKLINE>
+     [[ 6.381e-01  6.381e-01 -7.207e-17]
+      [-6.381e-01 -6.381e-01 -7.207e-17]]]
+
+    Through the functional interface we can compute the same fields for the loop as:
+
+    >>> obs = [(.01,.01,.01), (.01,.01,-.01)]
+    >>> H = magpy.getH('Circle', obs, current=100, diameter=.002)
+    >>> with np.printoptions(precision=3):
+    ...    print(H)
+    [[ 4.818  4.818  0.019]
+     [-4.818 -4.818  0.019]]
+
+    But also for a set of four completely different instances:
+
+    >>> H = magpy.getH(
+    ...     'Circle',
+    ...     observers=((.01,.01,.01), (.01,.01,-.01), (.01,.02,.03), (.02,.02,.02)),
+    ...     current=(11, 22, 33, 44),
+    ...     diameter=(.001, .002, .003, .004),
+    ...     position=((0,0,0), (0,0,.01), (0,0,.02), (0,0,.03)),
+    ... )
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [[ 1.324e-01  1.324e-01  1.287e-04]
+     [-3.736e-01 -3.736e-01  3.746e-01]
+     [ 6.342e-01  1.268e+00 -6.297e-01]
+     [-1.093e+00 -1.093e+00 -1.087e+00]]
+    """
+    return getBH_level2(
+        sources,
+        observers,
+        field="H",
+        sumup=sumup,
+        squeeze=squeeze,
+        pixel_agg=pixel_agg,
+        output=output,
+        in_out=in_out,
+        **kwargs,
+    )
+
+
+def getM(
+    sources=None,
+    observers=None,
+    sumup=False,
+    squeeze=True,
+    pixel_agg=None,
+    output="ndarray",
+    in_out="auto",
+    **kwargs,
+):
+    """Compute M-field in units of A/m for given sources and observers.
+
+    Field implementations can be directly accessed (avoiding the object oriented
+    Magpylib interface) by providing a string input `sources=source_type`, array_like
+    positions as `observers` input, and all other necessary input parameters (see below)
+    as kwargs.
+
+    Parameters
+    ----------
+    sources: source and collection objects or 1D list thereof
+        Sources that generate the magnetic field. Can be a single source (or collection)
+        or a 1D list of l sources and/or collection objects.
+
+        Functional interface: input must be one of (`Cuboid`, `Cylinder`, `CylinderSegment`,
+        `Sphere`, `Dipole`, `Circle` or `Polyline`).
+
+    observers: array_like or (list of) `Sensor` objects
+        Can be array_like positions of shape (n1, n2, ..., 3) where the field
+        should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+        of such sensor objects (must all have similar pixel shapes). All positions
+        are given in units of m.
+
+        Functional interface: Input must be array_like with shape (3,) or (n,3) corresponding
+        positions to observer positions in units of m.
+
+    sumup: bool, default=`False`
+        If `True`, the fields of all sources are summed up.
+
+    squeeze: bool, default=`True`
+        If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+        a single sensor or only a single source) are eliminated.
+
+    pixel_agg: str, default=`None`
+        Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+        which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+        With this option, observers input with different (pixel) shapes is allowed.
+
+    output: str, default=`'ndarray'`
+        Output type, which must be one of (`'ndarray'`, `'dataframe'`). By default a
+        `numpy.ndarray` object is returned. If `'dataframe'` is chosen, a `pandas.DataFrame`
+        object is returned (the Pandas library must be installed).
+
+    in_out: {'auto', 'inside', 'outside'}
+        This parameter only applies for magnet bodies. It specifies the location of the
+        observers relative to the magnet body, affecting the calculation of the magnetic field.
+        The options are:
+        - 'auto': The location (inside or outside the cuboid) is determined automatically for
+        each observer.
+        - 'inside': All observers are considered to be inside the cuboid; use this for
+            performance optimization if applicable.
+        - 'outside': All observers are considered to be outside the cuboid; use this for
+            performance optimization if applicable.
+        Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+        locations is unknown.
+
+    See Also
+    --------
+    *Functional interface
+
+    position: array_like, shape (3,) or (n,3), default=`(0,0,0)`
+        Source position(s) in the global coordinates in units of m.
+
+    orientation: scipy `Rotation` object with length 1 or n, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation.
+
+    magnetization: array_like, shape (3,) or (n,3)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`, `Sphere`)!
+        Magnetization vector(s) (mu0*M, remanence field) in units of A/m given in
+        the local object coordinates (rotates with object).
+
+    moment: array_like, shape (3) or (n,3), unit A·m²
+        Only source_type == `Dipole`!
+        Magnetic dipole moment(s) in units of A·m² given in the local object coordinates
+        (rotates with object). For homogeneous magnets the relation moment=magnetization*volume
+        holds.
+
+    current: array_like, shape (n,)
+        Only source_type == `Circle` or `Polyline`!
+        Electrical current in units of A.
+
+    dimension: array_like, shape (x,) or (n,x)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`)!
+        Magnet dimension input in units of m and deg. Dimension format x of sources is similar
+        as in object oriented interface.
+
+    diameter: array_like, shape (n,)
+        Only source_type == `Sphere` or `Circle`!
+        Diameter of source in units of m.
+
+    segment_start: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        Start positions of line current segments in units of m.
+
+    segment_end: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        End positions of line current segments in units of m.
+
+    Returns
+    -------
+    M-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+        M-field at each path position (index m) for each sensor (index k) and each sensor pixel
+        position (indices n1, n2, ...) in units of A/m. Sensor pixel positions are equivalent
+        to simple observer positions. Paths of objects that are shorter than index m are
+        considered as static beyond their end.
+
+    Functional interface: ndarray, shape (n,3)
+        M-field for every parameter set in units of A/m.
+
+    Examples
+    --------
+    In this example we test the magnetization at an observer point.
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> cube = magpy.magnet.Cuboid(
+    ...     dimension=(10,1,1),
+    ...     polarization=(1,0,0)
+    ... ).rotate_from_angax(45,'z')
+    >>> M = cube.getM((3,3,0))
+    >>> with np.printoptions(precision=0):
+    ...    print(M)
+    [562698. 562698.      0.]
+
+    Notes
+    -----
+    This function automatically joins all sensor and position inputs together and groups
+    similar sources for optimal vectorization of the computation. For maximal performance
+    call this function as little as possible and avoid using it in loops.
+    """
+    return getBH_level2(
+        sources,
+        observers,
+        field="M",
+        sumup=sumup,
+        squeeze=squeeze,
+        pixel_agg=pixel_agg,
+        output=output,
+        in_out=in_out,
+        **kwargs,
+    )
+
+
+def getJ(
+    sources=None,
+    observers=None,
+    sumup=False,
+    squeeze=True,
+    pixel_agg=None,
+    output="ndarray",
+    in_out="auto",
+    **kwargs,
+):
+    """Compute J-field in units of T for given sources and observers.
+
+    Field implementations can be directly accessed (avoiding the object oriented
+    Magpylib interface) by providing a string input `sources=source_type`, array_like
+    positions as `observers` input, and all other necessary input parameters (see below)
+    as kwargs.
+
+    Parameters
+    ----------
+    sources: source and collection objects or 1D list thereof
+        Sources that generate the magnetic field. Can be a single source (or collection)
+        or a 1D list of l sources and/or collection objects.
+
+        Functional interface: input must be one of (`Cuboid`, `Cylinder`, `CylinderSegment`,
+        `Sphere`, `Dipole`, `Circle` or `Polyline`).
+
+    observers: array_like or (list of) `Sensor` objects
+        Can be array_like positions of shape (n1, n2, ..., 3) where the field
+        should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+        of such sensor objects (must all have similar pixel shapes). All positions
+        are given in units of m.
+
+        Functional interface: Input must be array_like with shape (3,) or (n,3) corresponding
+        positions to observer positions in units of m.
+
+    sumup: bool, default=`False`
+        If `True`, the fields of all sources are summed up.
+
+    squeeze: bool, default=`True`
+        If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+        a single sensor or only a single source) are eliminated.
+
+    pixel_agg: str, default=`None`
+        Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+        which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+        With this option, observers input with different (pixel) shapes is allowed.
+
+    output: str, default=`'ndarray'`
+        Output type, which must be one of (`'ndarray'`, `'dataframe'`). By default a
+        `numpy.ndarray` object is returned. If `'dataframe'` is chosen, a `pandas.DataFrame`
+        object is returned (the Pandas library must be installed).
+
+    in_out: {'auto', 'inside', 'outside'}
+        This parameter only applies for magnet bodies. It specifies the location of the
+        observers relative to the magnet body, affecting the calculation of the magnetic field.
+        The options are:
+        - 'auto': The location (inside or outside the cuboid) is determined automatically for
+        each observer.
+        - 'inside': All observers are considered to be inside the cuboid; use this for
+            performance optimization if applicable.
+        - 'outside': All observers are considered to be outside the cuboid; use this for
+            performance optimization if applicable.
+        Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+        locations is unknown.
+
+    See Also
+    --------
+    *Functional interface
+
+    position: array_like, shape (3,) or (n,3), default=`(0,0,0)`
+        Source position(s) in the global coordinates in units of m.
+
+    orientation: scipy `Rotation` object with length 1 or n, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation.
+
+    magnetization: array_like, shape (3,) or (n,3)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`, `Sphere`)!
+        Magnetization vector(s) (mu0*M, remanence field) in units of A/m given in
+        the local object coordinates (rotates with object).
+
+    moment: array_like, shape (3) or (n,3), unit A·m²
+        Only source_type == `Dipole`!
+        Magnetic dipole moment(s) in units of A·m² given in the local object coordinates
+        (rotates with object). For homogeneous magnets the relation moment=magnetization*volume
+        holds.
+
+    current: array_like, shape (n,)
+        Only source_type == `Circle` or `Polyline`!
+        Electrical current in units of A.
+
+    dimension: array_like, shape (x,) or (n,x)
+        Only source_type in (`Cuboid`, `Cylinder`, `CylinderSegment`)!
+        Magnet dimension input in units of m and deg. Dimension format x of sources is similar
+        as in object oriented interface.
+
+    diameter: array_like, shape (n,)
+        Only source_type == `Sphere` or `Circle`!
+        Diameter of source in units of m.
+
+    segment_start: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        Start positions of line current segments in units of m.
+
+    segment_end: array_like, shape (n,3)
+        Only source_type == `Polyline`!
+        End positions of line current segments in units of m.
+
+    Returns
+    -------
+    J-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+        J-field at each path position (index m) for each sensor (index k) and each sensor pixel
+        position (indices n1, n2, ...) in units of T. Sensor pixel positions are equivalent
+        to simple observer positions. Paths of objects that are shorter than index m are
+        considered as static beyond their end.
+
+    Functional interface: ndarray, shape (n,3)
+        J-field for every parameter set in units of T.
+
+    Returns
+    -------
+    M-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+        M-field at each path position (index m) for each sensor (index k) and each sensor pixel
+        position (indices n1, n2, ...) in units of A/m. Sensor pixel positions are equivalent
+        to simple observer positions. Paths of objects that are shorter than index m are
+        considered as static beyond their end.
+
+    Functional interface: ndarray, shape (n,3)
+        M-field for every parameter set in units of A/m.
+
+    Examples
+    --------
+    In this example we test the polarization at an observer point.
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> cube = magpy.magnet.Cuboid(
+    ...     dimension=(10,1,1),
+    ...     polarization=(1,0,0)
+    ... ).rotate_from_angax(45,'z')
+    >>> J = cube.getJ((3,3,0))
+    >>> with np.printoptions(precision=3):
+    ...    print(J)
+    [0.707 0.707 0.   ]
+
+    Notes
+    -----
+    This function automatically joins all sensor and position inputs together and groups
+    similar sources for optimal vectorization of the computation. For maximal performance
+    call this function as little as possible and avoid using it in loops.
+
+    """
+    return getBH_level2(
+        sources,
+        observers,
+        field="J",
+        sumup=sumup,
+        squeeze=squeeze,
+        pixel_agg=pixel_agg,
+        output=output,
+        in_out=in_out,
+        **kwargs,
+    )
diff --git a/src/magpylib/_src/fields/special_cel.py b/src/magpylib/_src/fields/special_cel.py
new file mode 100644
index 000000000..149dc6880
--- /dev/null
+++ b/src/magpylib/_src/fields/special_cel.py
@@ -0,0 +1,190 @@
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import math as m
+
+import numpy as np
+
+
+def cel0(kc, p, c, s):
+    """
+    complete elliptic integral algorithm vom Kirby2009
+    """
+    if kc == 0:
+        msg = "FAIL"
+        raise RuntimeError(msg)
+    errtol = 0.000001
+    k = abs(kc)
+    pp = p
+    cc = c
+    ss = s
+    em = 1.0
+    if p > 0:
+        pp = np.sqrt(p)
+        ss = s / pp
+    else:
+        f = kc * kc
+        q = 1.0 - f
+        g = 1.0 - pp
+        f = f - pp
+        q = q * (ss - c * pp)
+        pp = np.sqrt(f / g)
+        cc = (c - ss) / g
+        ss = -q / (g * g * pp) + cc * pp
+    f = cc
+    cc = cc + ss / pp
+    g = k / pp
+    ss = 2 * (ss + f * g)
+    pp = g + pp
+    g = em
+    em = k + em
+    kk = k
+    while abs(g - k) > g * errtol:
+        k = 2 * np.sqrt(kk)
+        kk = k * em
+        f = cc
+        cc = cc + ss / pp
+        g = kk / pp
+        ss = 2 * (ss + f * g)
+        pp = g + pp
+        g = em
+        em = k + em
+    return (np.pi / 2) * (ss + cc * em) / (em * (em + pp))
+
+
+def celv(kc, p, c, s):
+    """
+    vectorized version of the cel integral above
+    """
+
+    # if kc == 0:
+    #    return NaN
+    errtol = 0.000001
+    n = len(kc)
+
+    k = np.abs(kc)
+    em = np.ones(n, dtype=float)
+
+    cc = c.copy()
+    pp = p.copy()
+    ss = s.copy()
+
+    # apply a mask for evaluation of respective cases
+    mask = p <= 0
+
+    # if p>0:
+    pp[~mask] = np.sqrt(p[~mask])
+    ss[~mask] = s[~mask] / pp[~mask]
+
+    # else:
+    f = kc[mask] * kc[mask]
+    q = 1.0 - f
+    g = 1.0 - pp[mask]
+    f = f - pp[mask]
+    q = q * (ss[mask] - c[mask] * pp[mask])
+    pp[mask] = np.sqrt(f / g)
+    cc[mask] = (c[mask] - ss[mask]) / g
+    ss[mask] = -q / (g * g * pp[mask]) + cc[mask] * pp[mask]
+
+    f = cc.copy()
+    cc = cc + ss / pp
+    g = k / pp
+    ss = 2 * (ss + f * g)
+    pp = g + pp
+    g = em.copy()
+    em = k + em
+    kk = k.copy()
+
+    # define a mask that adjusts with every evaluation step so that only
+    # non-converged entries are further iterated.
+    mask = np.ones(n, dtype=bool)
+    while np.any(mask):
+        k[mask] = 2 * np.sqrt(kk[mask])
+        kk[mask] = k[mask] * em[mask]
+        f[mask] = cc[mask]
+        cc[mask] = cc[mask] + ss[mask] / pp[mask]
+        g[mask] = kk[mask] / pp[mask]
+        ss[mask] = 2 * (ss[mask] + f[mask] * g[mask])
+        pp[mask] = g[mask] + pp[mask]
+        g[mask] = em[mask]
+        em[mask] = k[mask] + em[mask]
+
+        # redefine mask
+        mask = np.abs(g - k) > g * errtol
+
+    return (np.pi / 2) * (ss + cc * em) / (em * (em + pp))
+
+
+def cel(kcv: np.ndarray, pv: np.ndarray, cv: np.ndarray, sv: np.ndarray) -> np.ndarray:
+    """
+    combine vectorized and non-vectorized implementations for improved performance
+
+    def ellipticK(x):
+        return elliptic((1-x)**(1/2.), 1, 1, 1)
+
+    def ellipticE(x):
+        return elliptic((1-x)**(1/2.), 1, 1, 1-x)
+
+    def ellipticPi(x, y):
+        return elliptic((1-y)**(1/2.), 1-x, 1, 1)
+    """
+    n_input = len(kcv)
+
+    if n_input < 10:
+        return np.array(
+            [cel0(kc, p, c, s) for kc, p, c, s in zip(kcv, pv, cv, sv, strict=False)]
+        )
+
+    return celv(kcv, pv, cv, sv)
+
+
+def cel_iter(qc, p, g, cc, ss, em, kk):
+    """
+    Iterative part of Bulirsch cel algorithm
+    """
+    # case1: scalar input
+    #   This cannot happen in core functions
+
+    # case2: small input vector - loop is faster than vectorized computation
+    n_input = len(qc)
+    if n_input < 15:
+        result = np.zeros(n_input)
+        for i in range(n_input):
+            result[i] = cel_iter0(qc[i], p[i], g[i], cc[i], ss[i], em[i], kk[i])
+
+    # case3: vectorized evaluation
+    return cel_iterv(qc, p, g, cc, ss, em, kk)
+
+
+def cel_iter0(qc, p, g, cc, ss, em, kk):
+    """
+    Iterative part of Bulirsch cel algorithm
+    """
+    while m.fabs(g - qc) >= qc * 1e-8:
+        qc = 2 * m.sqrt(kk)
+        kk = qc * em
+        f = cc
+        cc = cc + ss / p
+        g = kk / p
+        ss = 2 * (ss + f * g)
+        p = p + g
+        g = em
+        em = em + qc
+    return 1.5707963267948966 * (ss + cc * em) / (em * (em + p))
+
+
+def cel_iterv(qc, p, g, cc, ss, em, kk):
+    """
+    Iterative part of Bulirsch cel algorithm
+    """
+    while np.any(np.fabs(g - qc) >= qc * 1e-8):
+        qc = 2 * np.sqrt(kk)
+        kk = qc * em
+        f = cc
+        cc = cc + ss / p
+        g = kk / p
+        ss = 2 * (ss + f * g)
+        p = p + g
+        g = em
+        em = em + qc
+    return 1.5707963267948966 * (ss + cc * em) / (em * (em + p))
diff --git a/src/magpylib/_src/fields/special_el3.py b/src/magpylib/_src/fields/special_el3.py
new file mode 100644
index 000000000..e2578db9c
--- /dev/null
+++ b/src/magpylib/_src/fields/special_el3.py
@@ -0,0 +1,657 @@
+from __future__ import annotations
+
+import numpy as np
+
+from magpylib._src.fields.special_cel import cel
+
+# ruff: noqa: E741  # Avoid ambiguity with variable names
+
+
+def el30(x, kc, p):
+    """
+    incomplete elliptic integral
+
+    el3 from Numerical Calculation of Elliptic Integrals and Elliptic Functions
+    ROLAND BULIRSCH Numerische Mathematik 7, 78--90 (t965)
+    """
+    # pylint: disable=too-many-branches
+    # pylint: disable=too-many-statements
+    # pylint: disable=consider-swap-variables
+
+    if x == 0:
+        return 0.0
+
+    ye = 0.0
+    k = km2 = l = m = n = 0
+    bo = bk = False
+
+    D = 8
+    CA = 10.0 ** (-D / 2)
+    CB = 10.0 ** (-D - 2)
+    ND = D - 2
+    ln2 = np.log(2)
+    ra, rb, rr = np.zeros((3, ND - 1))
+    hh = x * x
+    f = p * hh
+    s = CA / (1 + np.abs(x)) if (kc == 0.0) else kc
+    t = s * s
+    pm = 0.5 * t
+    e = hh * t
+    z = np.abs(f)
+    r = np.abs(p)
+    h = 1.0 + hh
+    if e < 0.1 and z < 0.1 and t < 1 and r < 1:
+        for k in range(2, ND + 1):  # k is also a variable !!!
+            km2 = int(k - 2)
+            rb[km2] = 0.5 / k
+            ra[km2] = 1.0 - rb[km2]
+        zd = 0.5 / (ND + 1)
+        s = p + pm
+        for k in range(2, ND + 1):
+            km2 = int(k - 2)
+            rr[km2] = s
+            pm = pm * t * ra[km2]
+            s = s * p + pm
+        u = s * zd
+        s = u
+        bo = False
+        for k in range(ND, 1, -1):
+            km2 = int(k - 2)
+            u = u + (rr[km2] - u) * rb[km2]
+            bo = not bo
+            v = -u if bo else u
+            s = s * hh + v
+        if bo:
+            s = -s
+        u = (u + 1) * 0.5
+        return (u - s * h) * np.sqrt(h) * x + u * np.arcsinh(x)
+
+    w = 1 + f
+    if w == 0:
+        msg = "FAIL"
+        raise RuntimeError(msg)
+    p1 = CB / hh if p == 0.0 else p
+    s = np.abs(s)
+    y = np.abs(x)
+    g = p1 - 1.0
+    if g == 0.0:
+        g = CB
+
+    f = p1 - t
+    if f == 0.0:
+        f = CB * t
+    am = 1.0 - t
+    ap = 1.0 + e
+    r = p1 * h
+    fa = g / (f * p1)
+    bo = fa > 0.0
+    fa = np.abs(fa)
+    pz = np.abs(g * f)
+    de = np.sqrt(pz)
+    q = np.sqrt(np.abs(p1))
+    pm = min(0.5, pm)
+    pm = p1 - pm
+
+    if pm >= 0.0:
+        u = np.sqrt(r * ap)
+        v = y * de
+        if g < 0.0:
+            v = -v
+        d = 1.0 / q
+        c = 1.0
+    else:
+        u = np.sqrt(h * ap * pz)
+        ye = y * q
+        v = am * ye
+        q = -de / g
+        d = -am / de
+        c = 0.0
+        pz = ap - r
+
+    if bo:
+        r = v / u
+        z = 1.0
+        k = 1
+        if pm < 0.0:
+            h = y * np.sqrt(h / (ap * fa))
+            h = 1.0 / h - h
+            z = h - r - r
+            r = 2.0 + r * h
+            if r == 0.0:
+                r = CB
+            if z == 0.0:
+                z = h * CB
+            z = r = r / z
+            w = pz
+        u = u / w
+        v = v / w
+    else:
+        t = u + np.abs(v)
+        bk = True
+        if p1 < 0.0:
+            de = v / pz
+            ye = u * ye
+            ye = ye + ye
+            u = t / pz
+            v = (-f - g * e) / t
+            t = pz * np.abs(w)
+            z = (hh * r * f - g * ap + ye) / t
+            ye = ye / t
+        else:
+            de = v / w
+            ye = 0.0
+            u = (e + p1) / t
+            v = t / w
+            z = 1.0
+        if s > 1.0:
+            h = u
+            u = v
+            v = h
+    y = 1.0 / y
+    e = s
+    n = 1
+    t = 1.0
+    l = 0
+    m = 0
+    while True:
+        y = y - e / y
+        if y == 0.0:
+            y = np.sqrt(e) * CB
+        f = c
+        c = d / q + c
+        g = e / q
+        d = f * g + d
+        d = d + d
+        q = g + q
+        g = t
+        t = s + t
+        n = n + n
+        m = m + m
+
+        if bo:
+            if z < 0:
+                m = k + m
+            k = np.sign(r)
+            h = e / (u * u + v * v)
+            u = u * (1.0 + h)
+            v = v * (1.0 - h)
+        else:
+            r = u / v
+            h = z * r
+            z = h * z
+            hh = e / v
+            if bk:
+                de = de / u
+                ye = ye * (h + 1.0 / h) + de * (1.0 + r)
+                de = de * (u - hh)
+                bk = np.abs(ye) < 1.0
+            else:
+                b_crack = ln2
+                a_crack = np.log(x)
+                k = int(a_crack / b_crack) + 1
+                a_crack = a_crack - k * b_crack
+                m = np.exp(a_crack)
+                m = m + k
+
+        if np.abs(g - s) > CA * g:
+            if bo:
+                g = (1.0 / r - r) * 0.5
+                hh = u + v * g
+                h = g * u - v
+                if hh == 0.0:
+                    hh = u * CB
+                if h == 0.0:
+                    h = v * CB
+                z = r * h
+                r = hh / h
+            else:
+                u = u + e / u
+                v = v + hh
+            s = np.sqrt(e)
+            s = s + s
+            e = s * t
+            l = l + l
+            if y < 0.0:
+                l += 1
+        else:
+            break
+    if y < 0.0:
+        l += 1
+    e = np.arctan(t / y) + np.pi * l
+    e = e * (c * t + d) / (t * (t + q))
+    if bo:
+        h = v / (t + u)
+        z = 1.0 - r * h
+        h = r + h
+        if z == 0.0:
+            z = CB
+        if z < 0.0:
+            m = m + np.sign(h)
+        s = np.arctan(h / z) + m * np.pi
+    else:
+        s = np.arcsinh(ye) if bk else np.log(z) + m * ln2
+        s = s * 0.5
+    e = (e + np.sqrt(fa) * s) / n
+    return e if (x > 0.0) else -e
+
+
+def el3v(x, kc, p):
+    """
+    vectorized form of el3
+
+    el3 from Numerical Calculation of Elliptic Integrals and Elliptic Functions
+    ROLAND BULIRSCH Numerische Mathematik 7, 78--90 (t965)
+
+    for large N ~ 20x faster than loop
+    for N = 10 same speed
+    for N = 1 10x slower
+    """
+    # pylint: disable=too-many-branches
+    # pylint: disable=too-many-statements
+
+    nnn0 = len(x)
+    result0 = np.zeros(nnn0)
+
+    # return 0 when mask0
+    mask0 = x != 0
+    x = x[mask0]
+    kc = kc[mask0]
+    p = p[mask0]
+
+    nnn = len(x)
+    result = np.zeros(nnn)
+
+    D = 8
+    CA = 10.0 ** (-D / 2)
+    CB = 10.0 ** (-D - 2)
+    ND = D - 2
+    ln2 = np.log(2)
+    hh = x * x
+    f = p * hh
+
+    s = np.zeros(nnn)
+    mask1 = kc == 0
+    s[mask1] = CA / (1 + np.abs(x[mask1]))
+    s[~mask1] = kc[~mask1]
+    t = s * s
+    pm = 0.5 * t
+    e = hh * t
+    z = np.abs(f)
+    r = np.abs(p)
+    h = 1.0 + hh
+    mask2 = (e < 0.1) * (z < 0.1) * (t < 1) * (r < 1)
+    if any(mask2):
+        ra, rb, rr = np.zeros((3, ND - 1, np.sum(mask2)))
+        px, pmx, tx, hhx, hx, xx = (
+            p[mask2],
+            pm[mask2],
+            t[mask2],
+            hh[mask2],
+            h[mask2],
+            x[mask2],
+        )
+        for k in range(2, ND + 1):
+            km2 = int(k - 2)
+            rb[km2] = 0.5 / k
+            ra[km2] = 1.0 - rb[km2]
+        zd = 0.5 / (ND + 1)
+        sx = px + pmx
+        for k in range(2, ND + 1):
+            km2 = int(k - 2)
+            rr[km2] = sx
+            pmx = pmx * tx * ra[km2]
+            sx = sx * px + pmx
+        ux = sx * zd
+        sx = np.copy(ux)
+        bo = False
+        for k in range(ND, 1, -1):
+            km2 = int(k - 2)
+            ux = ux + (rr[km2] - ux) * rb[km2]
+            bo = not bo
+            vx = -ux if bo else ux
+            sx = sx * hhx + vx
+        if bo:
+            sx = -sx
+        ux = (ux + 1) * 0.5
+        result[mask2] = (ux - sx * hx) * np.sqrt(hx) * xx + ux * np.arcsinh(xx)
+
+    mask2x = ~mask2
+    p, pm, t, hh, h, x = (
+        p[mask2x],
+        pm[mask2x],
+        t[mask2x],
+        hh[mask2x],
+        h[mask2x],
+        x[mask2x],
+    )
+    f, e, z, s = f[mask2x], e[mask2x], z[mask2x], s[mask2x]
+    ye, k = np.zeros((2, len(p)))
+    bk = np.zeros(len(p), dtype=bool)
+
+    w = 1 + f
+    if np.any(w == 0):
+        msg = "FAIL"
+        raise RuntimeError(msg)
+
+    p1 = np.copy(p)
+    mask3 = p == 0
+    p1[mask3] = CB / hh[mask3]
+    s = np.abs(s)
+    y = np.abs(x)
+    g = p1 - 1.0
+    g[g == 0] = CB
+    f = p1 - t
+    mask4 = f == 0
+    f[mask4] = CB * t[mask4]
+    am = 1.0 - t
+    ap = 1.0 + e
+    r = p1 * h
+    fa = g / (f * p1)
+    bo = fa > 0.0
+    fa = np.abs(fa)
+    pz = np.abs(g * f)
+    de = np.sqrt(pz)
+    q = np.sqrt(np.abs(p1))
+    mask5 = pm > 0.5
+    pm[mask5] = 0.5
+    pm = p1 - pm
+
+    u, v, d, c = np.zeros((4, len(pm)))
+
+    mask6 = pm >= 0.0
+    if np.any(mask6):
+        u[mask6] = np.sqrt(r[mask6] * ap[mask6])
+        v[mask6] = y[mask6] * de[mask6] * np.sign(g[mask6])
+        d[mask6] = 1 / q[mask6]
+        c[mask6] = 1.0
+
+    mask6x = ~mask6
+    if np.any(mask6x):
+        u[mask6x] = np.sqrt(h[mask6x] * ap[mask6x] * pz[mask6x])
+        ye[mask6x] = y[mask6x] * q[mask6x]
+        v[mask6x] = am[mask6x] * ye[mask6x]
+        q[mask6x] = -de[mask6x] / g[mask6x]
+        d[mask6x] = -am[mask6x] / de[mask6x]
+        c[mask6x] = 0
+        pz[mask6x] = ap[mask6x] - r[mask6x]
+
+    if np.any(bo):
+        r[bo] = v[bo] / u[bo]
+        z[bo] = 1
+        k[bo] = 1
+
+        mask7 = bo * (pm < 0)
+        if np.any(mask7):
+            h[mask7] = y[mask7] * np.sqrt(h[mask7] / (ap[mask7] * fa[mask7]))
+            h[mask7] = 1 / h[mask7] - h[mask7]
+            z[mask7] = h[mask7] - 2 * r[mask7]
+            r[mask7] = 2 + r[mask7] * h[mask7]
+
+            mask7a = mask7 * (r == 0)
+            r[mask7a] = CB
+
+            mask7b = mask7 * (z == 0)
+            z[mask7b] = h[mask7b] * CB
+
+            z[mask7] = r[mask7] / z[mask7]
+            r[mask7] = np.copy(z[mask7])
+            w[mask7] = pz[mask7]
+
+        u[bo] = u[bo] / w[bo]
+        v[bo] = v[bo] / w[bo]
+
+    box = ~bo
+    if np.any(box):
+        t[box] = u[box] + np.abs(v[box])
+        bk[box] = True
+
+        mask8 = box * (p1 < 0)
+        if np.any(mask8):
+            de[mask8] = v[mask8] / pz[mask8]
+            ye[mask8] = u[mask8] * ye[mask8]
+            ye[mask8] = 2 * ye[mask8]
+            u[mask8] = t[mask8] / pz[mask8]
+            v[mask8] = (-f[mask8] - g[mask8] * e[mask8]) / t[mask8]
+            t[mask8] = pz[mask8] * np.abs(w[mask8])
+            z[mask8] = (
+                hh[mask8] * r[mask8] * f[mask8] - g[mask8] * ap[mask8] + ye[mask8]
+            ) / t[mask8]
+            ye[mask8] = ye[mask8] / t[mask8]
+
+        mask8x = box * (p1 >= 0)
+        if np.any(mask8x):
+            de[mask8x] = v[mask8x] / w[mask8x]
+            ye[mask8x] = 0
+            u[mask8x] = (e[mask8x] + p1[mask8x]) / t[mask8x]
+            v[mask8x] = t[mask8x] / w[mask8x]
+            z[mask8x] = 1.0
+
+        mask9 = box * (s > 1)
+        if np.any(mask9):
+            h[mask9] = u[mask9]
+            u[mask9] = v[mask9]
+            v[mask9] = h[mask9]
+
+    y = 1 / y
+    e = np.copy(s)
+    n, t = np.ones((2, len(p)))
+    m, l = np.zeros((2, len(p)))
+
+    mask10 = np.ones(len(p), dtype=bool)  # dynamic mask, changed in each loop iteration
+    while np.any(mask10):
+        y[mask10] = y[mask10] - e[mask10] / y[mask10]
+
+        mask11 = mask10 * (y == 0.0)
+        y[mask11] = np.sqrt(e[mask11]) * CB
+
+        f[mask10] = c[mask10]
+        c[mask10] = d[mask10] / q[mask10] + c[mask10]
+        g[mask10] = e[mask10] / q[mask10]
+        d[mask10] = f[mask10] * g[mask10] + d[mask10]
+        d[mask10] = 2 * d[mask10]
+        q[mask10] = g[mask10] + q[mask10]
+        g[mask10] = t[mask10]
+        t[mask10] = s[mask10] + t[mask10]
+        n[mask10] = 2 * n[mask10]
+        m[mask10] = 2 * m[mask10]
+        bo10 = mask10 * bo
+        if np.any(bo10):
+            bo10b = bo10 * (z < 0)
+            m[bo10b] = k[bo10b] + m[bo10b]
+
+            k[bo10] = np.sign(r[bo10])
+            h[bo10] = e[bo10] / (u[bo10] * u[bo10] + v[bo10] * v[bo10])
+            u[bo10] = u[bo10] * (1 + h[bo10])
+            v[bo10] = v[bo10] * (1 - h[bo10])
+
+        bo10x = np.array(mask10 * ~bo10, dtype=bool)
+        if np.any(bo10x):
+            r[bo10x] = u[bo10x] / v[bo10x]
+            h[bo10x] = z[bo10x] * r[bo10x]
+            z[bo10x] = h[bo10x] * z[bo10x]
+            hh[bo10x] = e[bo10x] / v[bo10x]
+
+            bo10x_bk = np.array(bo10x * bk, dtype=bool)  # if bk
+            bo10x_bkx = np.array(bo10x * ~bk, dtype=bool)
+            if np.any(bo10x_bk):
+                de[bo10x_bk] = de[bo10x_bk] / u[bo10x_bk]
+                ye[bo10x_bk] = ye[bo10x_bk] * (h[bo10x_bk] + 1 / h[bo10x_bk]) + de[
+                    bo10x_bk
+                ] * (1 + r[bo10x_bk])
+                de[bo10x_bk] = de[bo10x_bk] * (u[bo10x_bk] - hh[bo10x_bk])
+                bk[bo10x_bk] = np.abs(ye[bo10x_bk]) < 1
+            if np.any(bo10x_bkx):
+                a_crack = np.log(x[bo10x_bkx])
+                k[bo10x_bkx] = (a_crack / ln2).astype(int) + 1
+                a_crack = a_crack - k[bo10x_bkx] * ln2
+                m[bo10x_bkx] = np.exp(a_crack)
+                m[bo10x_bkx] = m[bo10x_bkx] + k[bo10x_bkx]
+
+        mask11 = np.abs(g - s) > CA * g
+        if np.any(mask11):
+            bo11 = mask11 * bo
+            if np.any(bo11):
+                g[bo11] = (1 / r[bo11] - r[bo11]) * 0.5
+                hh[bo11] = u[bo11] + v[bo11] * g[bo11]
+                h[bo11] = g[bo11] * u[bo11] - v[bo11]
+
+                bo11b = bo11 * (hh == 0)
+                hh[bo11b] = u[bo11b] * CB
+
+                bo11c = bo11 * (h == 0)
+                h[bo11c] = v[bo11c] * CB
+
+                z[bo11] = r[bo11] * h[bo11]
+                r[bo11] = hh[bo11] / h[bo11]
+
+            bo11x = mask11 * ~bo
+            if np.any(bo11x):
+                u[bo11x] = u[bo11x] + e[bo11x] / u[bo11x]
+                v[bo11x] = v[bo11x] + hh[bo11x]
+
+            s[mask11] = np.sqrt(e[mask11])
+            s[mask11] = 2 * s[mask11]
+            e[mask11] = s[mask11] * t[mask11]
+            l[mask11] = 2 * l[mask11]
+
+            mask12 = mask11 * (y < 0)
+            l[mask12] = l[mask12] + 1
+
+        # break off parts that have completed their iteration
+        mask10 = mask11
+
+    mask12 = y < 0
+    l[mask12] = l[mask12] + 1
+
+    e = np.arctan(t / y) + np.pi * l
+    e = e * (c * t + d) / (t * (t + q))
+
+    if np.any(bo):
+        h[bo] = v[bo] / (t[bo] + u[bo])
+        z[bo] = 1 - r[bo] * h[bo]
+        h[bo] = r[bo] + h[bo]
+
+        bob = bo * (z == 0)
+        z[bob] = CB
+
+        boc = bo * (z < 0)
+        m[boc] = m[boc] + np.sign(h[boc])
+
+        s[bo] = np.arctan(h[bo] / z[bo]) + m[bo] * np.pi
+
+    box = ~bo
+    if np.any(box):
+        box_bk = box * bk
+        s[box_bk] = np.arcsinh(ye[box_bk])
+
+        box_bkx = box * ~bk
+        s[box_bkx] = np.log(z[box_bkx]) + m[box_bkx] * ln2
+
+        s[box] = s[box] * 0.5
+    e = (e + np.sqrt(fa) * s) / n
+    result[~mask2] = np.sign(x) * e
+
+    # include mask0-case
+    result0[mask0] = result
+
+    return result0
+
+
+def el3(xv: np.ndarray, kcv: np.ndarray, pv: np.ndarray) -> np.ndarray:
+    """
+    combine vectorized and non-vectorized implementations for improved performance
+    """
+    n_input = len(xv)
+
+    if n_input < 10:
+        return np.array([el30(x, kc, p) for x, kc, p in zip(xv, kcv, pv, strict=False)])
+
+    return el3v(xv, kcv, pv)
+
+
+def el3_angle(phi: np.ndarray, n: np.ndarray, m: np.ndarray) -> np.ndarray:
+    """
+    vectorized implementation of incomplete elliptic integral for
+    arbitrary integration boundaries
+
+    there is still a lot to do here !!!!!
+
+    - cel and el3 are not collected !!!!!
+        -> collect all cel and el3 and evaluate in one single go !!!
+
+    - its somehow 2x slower than non-vectorized version when N=1 although
+        the underlying functions are used in non-vectorized form.
+        -> possibly provide a non-vectorized form of this ? (maybe not worth the effort)
+    """
+    # pylint: disable=too-many-statements
+    n_vec = len(phi)
+    results = np.zeros(n_vec)
+
+    kc = np.sqrt(1 - m)
+    p = 1 - n
+
+    D = 8
+    n = (phi / np.pi).astype(int)
+    phi_red = phi - n * np.pi
+
+    mask1 = (n <= 0) * (phi_red < -np.pi / 2)
+    mask2 = (n >= 0) * (phi_red > np.pi / 2)
+    if np.any(mask1):
+        n[mask1] = n[mask1] - 1
+        phi_red[mask1] = phi_red[mask1] + np.pi
+
+    if np.any(mask2):
+        n[mask2] = n[mask2] + 1
+        phi_red[mask2] = phi_red[mask2] - np.pi
+
+    mask3 = n != 0
+    mask3x = ~mask3
+    if np.any(mask3):
+        n3, phi3, p3, kc3 = n[mask3], phi[mask3], p[mask3], kc[mask3]
+        phi_red3 = phi_red[mask3]
+
+        results3 = np.zeros(np.sum(mask3))
+        onez = np.ones(np.sum(mask3))
+        cel3_res = cel(kc3, p3, onez, onez)  # 3rd kind cel
+
+        mask3a = phi_red3 > np.pi / 2 - 10 ** (-D)
+        mask3b = phi_red3 < -np.pi / 2 + 10 ** (-D)
+        mask3c = ~mask3a * ~mask3b
+
+        if np.any(mask3a):
+            results3[mask3a] = (2 * n3[mask3a] + 1) * cel3_res[mask3a]
+        if np.any(mask3b):
+            results3[mask3b] = (2 * n3[mask3b] - 1) * cel3_res[mask3b]
+        if np.any(mask3c):
+            el33_res = el3(np.tan(phi3[mask3c]), kc3[mask3c], p3[mask3c])
+            results3[mask3c] = 2 * n3[mask3c] * cel3_res[mask3c] + el33_res
+
+        results[mask3] = results3
+
+    if np.any(mask3x):
+        phi_red3x = phi_red[mask3x]
+        results3x = np.zeros(np.sum(mask3x))
+        phi3x, kc3x, p3x = phi[mask3x], kc[mask3x], p[mask3x]
+
+        mask3xa = phi_red3x > np.pi / 2 - 10 ** (-D)
+        mask3xb = phi_red3x < -np.pi / 2 + 10 ** (-D)
+        mask3xc = ~mask3xa * ~mask3xb
+
+        if np.any(mask3xa):
+            onez = np.ones(np.sum(mask3xa))
+            results3x[mask3xa] = cel(
+                kc3x[mask3xa], p3x[mask3xa], onez, onez
+            )  # 3rd kind cel
+        if np.any(mask3xb):
+            onez = np.ones(np.sum(mask3xb))
+            results3x[mask3xb] = -cel(
+                kc3x[mask3xb], p3x[mask3xb], onez, onez
+            )  # 3rd kind cel
+        if np.any(mask3xc):
+            results3x[mask3xc] = el3(
+                np.tan(phi3x[mask3xc]), kc3x[mask3xc], p3x[mask3xc]
+            )
+
+        results[mask3x] = results3x
+
+    return results
diff --git a/src/magpylib/_src/input_checks.py b/src/magpylib/_src/input_checks.py
new file mode 100644
index 000000000..6b6be3717
--- /dev/null
+++ b/src/magpylib/_src/input_checks.py
@@ -0,0 +1,665 @@
+"""input checks code"""
+
+# pylint: disable=import-outside-toplevel
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import inspect
+import numbers
+from importlib.util import find_spec
+
+import numpy as np
+from scipy.spatial.transform import Rotation
+
+from magpylib import _src
+from magpylib._src.defaults.defaults_classes import default_settings
+from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS
+from magpylib._src.exceptions import MagpylibBadUserInput, MagpylibMissingInput
+from magpylib._src.utility import format_obj_input, wrong_obj_msg
+
+# pylint: disable=no-member
+
+#################################################################
+#################################################################
+# FUNDAMENTAL CHECKS
+
+
+def all_same(lst: list) -> bool:
+    """test if all list entries are the same"""
+    return lst[1:] == lst[:-1]
+
+
+def is_array_like(inp, msg: str):
+    """test if inp is array_like: type list, tuple or ndarray
+    inp: test object
+    msg: str, error msg
+    """
+    if not isinstance(inp, list | tuple | np.ndarray):
+        raise MagpylibBadUserInput(msg)
+
+
+def make_float_array(inp, msg: str):
+    """transform inp to array with dtype=float, throw error with bad input
+    inp: test object
+    msg: str, error msg
+    """
+    try:
+        inp_array = np.array(inp, dtype=float)
+    except Exception as err:
+        raise MagpylibBadUserInput(msg + f"{err}") from err
+    return inp_array
+
+
+def check_array_shape(inp: np.ndarray, dims: tuple, shape_m1: int, length=None, msg=""):
+    """check if inp shape is allowed
+    inp: test object
+    dims: list, list of allowed dims
+    shape_m1: shape of lowest level, if 'any' allow any shape
+    msg: str, error msg
+    """
+    if inp.ndim in dims:
+        if length is None:
+            if inp.shape[-1] == shape_m1:
+                return
+            if shape_m1 == "any":
+                return
+        elif len(inp) == length:
+            return
+    raise MagpylibBadUserInput(msg)
+
+
+def check_input_zoom(inp):
+    """check show zoom input"""
+    if not (isinstance(inp, numbers.Number) and inp >= 0):
+        msg = (
+            "Input parameter `zoom` must be a positive number or zero.\n"
+            f"Instead received {inp!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+
+
+def check_input_animation(inp):
+    """check show animation input"""
+    ERR_MSG = (
+        "Input parameter `animation` must be boolean or a positive number.\n"
+        f"Instead received {inp!r}."
+    )
+    if not isinstance(inp, numbers.Number):
+        raise MagpylibBadUserInput(ERR_MSG)
+    if inp < 0:
+        raise MagpylibBadUserInput(ERR_MSG)
+
+
+#################################################################
+#################################################################
+# SIMPLE CHECKS
+
+
+def check_start_type(inp):
+    """start input must be int or str"""
+    if not (
+        isinstance(inp, int | np.integer) or (isinstance(inp, str) and inp == "auto")
+    ):
+        msg = (
+            f"Input parameter `start` must be integer value or 'auto'.\n"
+            f"Instead received {inp!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+
+
+def check_degree_type(inp):
+    """degrees input must be bool"""
+    if not isinstance(inp, bool):
+        msg = (
+            "Input parameter `degrees` must be boolean (`True` or `False`).\n"
+            f"Instead received {inp!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+
+
+def check_field_input(inp):
+    """check field input"""
+    allowed = tuple("BHMJ")
+    if not (isinstance(inp, str) and inp in allowed):
+        msg = f"`field` input can only be one of {allowed}.\nInstead received {inp!r}."
+        raise MagpylibBadUserInput(msg)
+
+
+def validate_field_func(val):
+    """test if field function for custom source is valid
+    - needs to be a callable
+    - input and output shape must match
+    """
+    if val is None:
+        return
+
+    if not callable(val):
+        msg = (
+            "Input parameter `field_func` must be a callable.\n"
+            f"Instead received {type(val).__name__!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+
+    fn_args = inspect.getfullargspec(val).args
+    if fn_args[:2] != ["field", "observers"]:
+        msg = (
+            "Input parameter `field_func` must have two positional args"
+            " called 'field' and 'observers'.\n"
+            f"Instead received a callable where the first two args are: {fn_args[:2]!r}"
+        )
+        raise MagpylibBadUserInput(msg)
+
+    for field in ["B", "H"]:
+        out = val(field, np.array([[1, 2, 3], [4, 5, 6]]))
+        if out is not None:
+            if not isinstance(out, np.ndarray):
+                msg = (
+                    "Input parameter `field_func` must be a callable that returns B- and H-field"
+                    " as numpy ndarray.\n"
+                    f"Instead it returns type {type(out)!r} for {field}-field."
+                )
+                raise MagpylibBadUserInput(msg)
+            if out.shape != (2, 3):
+                msg = (
+                    "Input parameter `field_func` must be a callable that returns B- and H-field"
+                    " as numpy ndarray with shape (n,3), when `observers` input is shape (n,3).\n"
+                    f"Instead it returns shape {out.shape} for {field}-field for input shape "
+                    "(2,3)"
+                )
+                raise MagpylibBadUserInput(msg)
+
+    return
+
+
+#################################################################
+#################################################################
+# CHECK - FORMAT
+
+
+def check_format_input_orientation(inp, init_format=False):
+    """checks orientation input returns in formatted form
+    - inp must be None or Rotation object
+    - transform None to unit rotation as quat (0,0,0,1)
+    if init_format: (for move method)
+        return inp and inpQ
+    else: (for init and setter)
+        return inpQ in shape (-1,4)
+
+    This function is used for setter and init only -> shape (1,4) and (4,) input
+    creates same behavior.
+    """
+    # check type
+    if not isinstance(inp, Rotation | type(None)):
+        msg = (
+            f"Input parameter `orientation` must be `None` or scipy `Rotation` object.\n"
+            f"Instead received type {type(inp)!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+    # handle None input and compute inpQ
+    if inp is None:
+        inpQ = np.array((0, 0, 0, 1))
+        inp = Rotation.from_quat(inpQ)
+    else:
+        inpQ = inp.as_quat()
+    # return
+    if init_format:
+        return np.reshape(inpQ, (-1, 4))
+    return inp, inpQ
+
+
+def check_format_input_anchor(inp):
+    """checks rotate anchor input and return in formatted form
+    - input must be array_like or None or 0
+    """
+    if isinstance(inp, numbers.Number) and inp == 0:
+        return np.array((0.0, 0.0, 0.0))
+
+    return check_format_input_vector(
+        inp,
+        dims=(1, 2),
+        shape_m1=3,
+        sig_name="anchor",
+        sig_type="`None` or `0` or array_like (list, tuple, ndarray) with shape (3,)",
+        allow_None=True,
+    )
+
+
+def check_format_input_axis(inp):
+    """check rotate_from_angax axis input and return in formatted form
+    - input must be array_like or str
+    - if string 'x'->(1,0,0), 'y'->(0,1,0), 'z'->(0,0,1)
+    - convert inp to ndarray with dtype float
+    - inp shape must be (3,)
+    - axis must not be (0,0,0)
+    - return as ndarray shape (3,)
+    """
+    if isinstance(inp, str):
+        if inp == "x":
+            return np.array((1, 0, 0))
+        if inp == "y":
+            return np.array((0, 1, 0))
+        if inp == "z":
+            return np.array((0, 0, 1))
+        msg = (
+            "Input parameter `axis` must be array_like shape (3,) or one of ['x', 'y', 'z'].\n"
+            f"Instead received string {inp!r}.\n"
+        )
+        raise MagpylibBadUserInput(msg)
+
+    inp = check_format_input_vector(
+        inp,
+        dims=(1,),
+        shape_m1=3,
+        sig_name="axis",
+        sig_type="array_like (list, tuple, ndarray) with shape (3,) or one of ['x', 'y', 'z']",
+    )
+
+    if np.all(inp == 0):
+        msg = "Input parameter `axis` must not be (0,0,0).\n"
+        raise MagpylibBadUserInput(msg)
+    return inp
+
+
+def check_format_input_angle(inp):
+    """check rotate_from_angax angle input and return in formatted form
+    - must be scalar (int/float) or array_like
+    - if scalar
+        - return float
+    - if array_like
+        - convert inp to ndarray with dtype float
+        - inp shape must be (n,)
+        - return as ndarray
+    """
+    if isinstance(inp, numbers.Number):
+        return float(inp)
+
+    return check_format_input_vector(
+        inp,
+        dims=(1,),
+        shape_m1="any",
+        sig_name="angle",
+        sig_type="int, float or array_like (list, tuple, ndarray) with shape (n,)",
+    )
+
+
+def check_format_input_scalar(
+    inp, sig_name, sig_type, allow_None=False, forbid_negative=False
+):
+    """check scalar input and return in formatted form
+    - must be scalar or None (if allowed)
+    - must be float compatible
+    - transform into float
+    """
+    if allow_None and inp is None:
+        return None
+
+    ERR_MSG = (
+        f"Input parameter `{sig_name}` must be {sig_type}.\nInstead received {inp!r}."
+    )
+
+    if not isinstance(inp, numbers.Number):
+        raise MagpylibBadUserInput(ERR_MSG)
+
+    inp = float(inp)
+
+    if forbid_negative and inp < 0:
+        raise MagpylibBadUserInput(ERR_MSG)
+    return inp
+
+
+def check_format_input_vector(
+    inp,
+    dims,
+    shape_m1,
+    sig_name,
+    sig_type,
+    length=None,
+    reshape=False,
+    allow_None=False,
+    forbid_negative0=False,
+):
+    """checks vector input and returns in formatted form
+    - inp must be array_like
+    - convert inp to ndarray with dtype float
+    - inp shape must be given by dims and shape_m1
+    - print error msg with signature arguments
+    - if reshape=True: returns shape (n,3) - required for position init and setter
+    - if allow_None: return None
+    - if extend_dim_to2: add a dimension if input is only (1,2,3) - required for sensor pixel
+    """
+    if allow_None and inp is None:
+        return None
+
+    is_array_like(
+        inp,
+        f"Input parameter `{sig_name}` must be {sig_type}.\n"
+        f"Instead received type {type(inp)!r}.",
+    )
+    inp = make_float_array(
+        inp,
+        f"Input parameter `{sig_name}` must contain only float compatible entries.\n",
+    )
+    check_array_shape(
+        inp,
+        dims=dims,
+        shape_m1=shape_m1,
+        length=length,
+        msg=(
+            f"Input parameter `{sig_name}` must be {sig_type}.\n"
+            f"Instead received array_like with shape {inp.shape}."
+        ),
+    )
+    if isinstance(reshape, tuple):
+        return np.reshape(inp, reshape)
+
+    if forbid_negative0 and np.any(inp <= 0):
+        msg = f"Input parameter `{sig_name}` cannot have values <= 0."
+        raise MagpylibBadUserInput(msg)
+    return inp
+
+
+def check_format_input_vector2(
+    inp,
+    shape,
+    param_name,
+):
+    """checks vector input and returns in formatted form
+    - inp must be array_like
+    - convert inp to ndarray with dtype float
+    - make sure that inp.ndim = target_ndim, None dimensions are ignored
+    """
+    is_array_like(
+        inp,
+        f"Input parameter `{param_name}` must be array_like.\n"
+        f"Instead received type {type(inp)!r}.",
+    )
+    inp = make_float_array(
+        inp,
+        f"Input parameter `{param_name}` must contain only float compatible entries.\n",
+    )
+    for d1, d2 in zip(inp.shape, shape, strict=False):
+        if d2 is not None and d1 != d2:
+            msg = f"Input parameter `{param_name}` has bad shape."
+            raise ValueError(msg)
+    return inp
+
+
+def check_format_input_vertices(inp):
+    """checks vertices input and returns in formatted form
+    - vector check with dim = (n,3) but n must be >=2
+    """
+    inp = check_format_input_vector(
+        inp,
+        dims=(2,),
+        shape_m1=3,
+        sig_name="vertices",
+        sig_type="`None` or array_like (list, tuple, ndarray) with shape (n,3)",
+        allow_None=True,
+    )
+
+    if inp is not None and inp.shape[0] < 2:
+        msg = "Input parameter `vertices` must have more than one vertex."
+        raise MagpylibBadUserInput(msg)
+    return inp
+
+
+def check_format_input_cylinder_segment(inp):
+    """checks vertices input and returns in formatted form
+    - vector check with dim = (5) or none
+    - check if d1<d2, phi1<phi2
+    - check if phi2-phi1 > 360
+    - return error msg
+    """
+    inp = check_format_input_vector(
+        inp,
+        dims=(1,),
+        shape_m1=5,
+        sig_name="CylinderSegment.dimension",
+        sig_type=(
+            "array_like of the form (r1, r2, h, phi1, phi2) with r1<r2,"
+            "phi1<phi2 and phi2-phi1<=360"
+        ),
+        allow_None=True,
+    )
+
+    if inp is None:
+        return None
+
+    r1, r2, h, phi1, phi2 = inp
+    case2 = r1 > r2
+    case3 = phi1 > phi2
+    case4 = (phi2 - phi1) > 360
+    case5 = (r1 < 0) | (r2 <= 0) | (h <= 0)
+    if case2 | case3 | case4 | case5:
+        msg = (
+            f"Input parameter `CylinderSegment.dimension` must be array_like of the form"
+            f" (r1, r2, h, phi1, phi2) with 0<=r1<r2, h>0, phi1<phi2 and phi2-phi1<=360,"
+            f"\nInstead received {inp!r}."
+        )
+        raise MagpylibBadUserInput(msg)
+    return inp
+
+
+def check_format_input_backend(inp):
+    """checks show-backend input and returns Non if bad input value"""
+    backends = [*SUPPORTED_PLOTTING_BACKENDS, "auto"]
+    if inp is None:
+        inp = default_settings.display.backend
+    if inp in backends:
+        return inp
+    msg = (
+        f"Input parameter `backend` must be one of `{[*backends, None]}`."
+        f"\nInstead received {inp!r}."
+    )
+    raise MagpylibBadUserInput(msg)
+
+
+def check_format_input_observers(inp, pixel_agg=None):
+    """
+    checks observers input and returns a list of sensor objects
+    """
+    # pylint: disable=raise-missing-from
+    from magpylib._src.obj_classes.class_Collection import Collection
+    from magpylib._src.obj_classes.class_Sensor import Sensor
+
+    # make bare Sensor, bare Collection into a list
+    if isinstance(inp, Collection | Sensor):
+        inp = (inp,)
+
+    # note: bare pixel is automatically made into a list by Sensor
+
+    # any good input must now be list/tuple/array
+    if not isinstance(inp, list | tuple | np.ndarray):
+        raise MagpylibBadUserInput(wrong_obj_msg(inp, allow="observers"))
+
+    # empty list
+    if len(inp) == 0:
+        raise MagpylibBadUserInput(wrong_obj_msg(inp, allow="observers"))
+
+    # now inp can still be [pos_vec, sens, coll] or just a pos_vec
+
+    try:  # try if input is just a pos_vec
+        inp = np.array(inp, dtype=float)
+        pix_shapes = [(1, 3) if inp.shape == (3,) else inp.shape]
+        return [_src.obj_classes.class_Sensor.Sensor(pixel=inp)], pix_shapes
+    except (TypeError, ValueError) as err:  # if not, it must be [pos_vec, sens, coll]
+        sensors = []
+        for obj_item in inp:
+            obj = obj_item
+            if isinstance(obj, Sensor):
+                sensors.append(obj)
+            elif isinstance(obj, Collection):
+                child_sensors = format_obj_input(obj, allow="sensors")
+                if not child_sensors:
+                    raise MagpylibBadUserInput(
+                        wrong_obj_msg(obj, allow="observers")
+                    ) from err
+                sensors.extend(child_sensors)
+            else:  # if its not a Sensor or a Collection it can only be a pos_vec
+                try:
+                    obj = np.array(obj, dtype=float)
+                    sensors.append(_src.obj_classes.class_Sensor.Sensor(pixel=obj))
+                except Exception:  # or some unwanted crap
+                    raise MagpylibBadUserInput(
+                        wrong_obj_msg(obj, allow="observers")
+                    ) from err
+
+        # all pixel shapes must be the same
+        pix_shapes = [
+            (1, 3) if (s.pixel is None or s.pixel.shape == (3,)) else s.pixel.shape
+            for s in sensors
+        ]
+        if pixel_agg is None and not all_same(pix_shapes):
+            msg = (
+                "Different observer input shape detected."
+                " All observer inputs must be of similar shape, unless a"
+                " numpy pixel aggregator is provided, e.g. `pixel_agg='mean'`!"
+            )
+            raise MagpylibBadUserInput(msg) from err
+        return sensors, pix_shapes
+
+
+def check_format_input_obj(
+    inp,
+    allow: str,
+    recursive=True,
+    typechecks=False,
+) -> list:
+    """
+    Returns a flat list of all wanted objects in input.
+    Parameters
+    ----------
+    input: can be
+        - objects
+    allow: str
+        Specify which object types are wanted, separate by +,
+        e.g. sensors+collections+sources
+    recursive: bool
+        Flatten Collection objects
+    """
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+    from magpylib._src.obj_classes.class_Collection import Collection
+    from magpylib._src.obj_classes.class_Sensor import Sensor
+
+    # select wanted
+    wanted_types = ()
+    if "sources" in allow.split("+"):
+        wanted_types += (BaseSource,)
+    if "sensors" in allow.split("+"):
+        wanted_types += (Sensor,)
+    if "collections" in allow.split("+"):
+        wanted_types += (Collection,)
+
+    obj_list = []
+    for obj in inp:
+        # add to list if wanted type
+        if isinstance(obj, wanted_types):
+            obj_list.append(obj)
+
+        # recursion
+        if isinstance(obj, Collection) and recursive:
+            obj_list += check_format_input_obj(
+                obj,
+                allow=allow,
+                recursive=recursive,
+                typechecks=typechecks,
+            )
+
+        # typechecks
+        # pylint disable possibly-used-before-assignment
+        if typechecks and not isinstance(obj, BaseSource | Sensor | Collection):
+            msg = (
+                f"Input objects must be {allow} or a flat list thereof.\n"
+                f"Instead received {type(obj)!r}."
+            )
+            raise MagpylibBadUserInput(msg)
+
+    return obj_list
+
+
+############################################################################################
+############################################################################################
+# SHOW AND GETB CHECKS
+
+
+def check_dimensions(sources):
+    """check if all sources have dimension (or similar) initialized"""
+    for src in sources:
+        for arg in ("dimension", "diameter", "vertices"):
+            if hasattr(src, arg):
+                if getattr(src, arg) is None:
+                    msg = f"Parameter `{arg}` of {src} must be set."
+                    raise MagpylibMissingInput(msg)
+                break
+
+
+def check_excitations(sources):
+    """check if all sources have excitation initialized"""
+    for src in sources:
+        for arg in ("polarization", "current", "moment"):
+            if hasattr(src, arg):
+                if getattr(src, arg) is None:
+                    msg = f"Parameter `{arg}` of {src} must be set."
+                    raise MagpylibMissingInput(msg)
+                break
+
+
+def check_format_pixel_agg(pixel_agg):
+    """
+    check if pixel_agg input is acceptable
+    return the respective numpy function
+    """
+
+    PIXEL_AGG_ERR_MSG = (
+        "Input `pixel_agg` must be a reference to a numpy callable that reduces"
+        " an array shape like 'mean', 'std', 'median', 'min', ..."
+        f"\nInstead received {pixel_agg!r}."
+    )
+
+    if pixel_agg is None:
+        return None
+
+    # test numpy reference
+    try:
+        pixel_agg_func = getattr(np, pixel_agg)
+    except AttributeError as err:
+        raise AttributeError(PIXEL_AGG_ERR_MSG) from err
+
+    # test pixel agg function reduce
+    x = np.array([[[(1, 2, 3)] * 2] * 3] * 4)
+    if not isinstance(pixel_agg_func(x), numbers.Number):
+        raise AttributeError(PIXEL_AGG_ERR_MSG)
+
+    return pixel_agg_func
+
+
+def check_getBH_output_type(output):
+    """check if getBH output is acceptable"""
+    acceptable = ("ndarray", "dataframe")
+    if output not in acceptable:
+        msg = (
+            f"The `output` argument must be one of {acceptable}."
+            f"\nInstead received {output!r}."
+        )
+        raise ValueError(msg)
+    if output == "dataframe" and find_spec("pandas") is None:  # pragma: no cover
+        msg = (
+            "In order to use the `dataframe` output type, you need to install pandas "
+            "via pip or conda, "
+            "see https://pandas.pydata.org/docs/getting_started/install.html"
+        )
+        raise ModuleNotFoundError(msg)
+    return output
+
+
+def check_input_canvas_update(canvas_update, canvas):
+    """check if canvas_update is acceptable also depending on canvas input"""
+    acceptable = (True, False, "auto", None)
+    if canvas_update not in acceptable:
+        msg = (
+            f"The `canvas_update` must be one of {acceptable}"
+            f"\nInstead received {canvas_update!r}."
+        )
+        raise ValueError(msg)
+    return canvas is None if canvas_update in (None, "auto") else canvas_update
diff --git a/src/magpylib/_src/obj_classes/__init__.py b/src/magpylib/_src/obj_classes/__init__.py
new file mode 100644
index 000000000..5b95f68d0
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/__init__.py
@@ -0,0 +1 @@
+"""_src.obj_classes"""
diff --git a/src/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/src/magpylib/_src/obj_classes/class_BaseDisplayRepr.py
new file mode 100644
index 000000000..d015cef1b
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_BaseDisplayRepr.py
@@ -0,0 +1,123 @@
+"""BaseGeo class code"""
+
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-branches
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial.transform import Rotation
+
+from magpylib._src.display.display import show
+from magpylib._src.display.traces_core import make_DefaultTrace
+
+UNITS = {
+    "parent": None,
+    "position": "m",
+    "orientation": "deg",
+    "dimension": "m",
+    "diameter": "m",
+    "current": "A",
+    "magnetization": "A/m",
+    "polarization": "T",
+    "moment": "A·m²",
+}
+
+
+class BaseDisplayRepr:
+    """Provides the show and repr methods for all objects"""
+
+    show = show
+    get_trace = make_DefaultTrace
+
+    def _property_names_generator(self):
+        """returns a generator with class properties only"""
+        return (
+            attr
+            for attr in dir(self)
+            if isinstance(getattr(type(self), attr, None), property)
+        )
+
+    def _get_description(self, exclude=None):
+        """Returns list of string describing the object properties.
+
+        Parameters
+        ----------
+        exclude: bool, default=("style",)
+            properties to be excluded in the description view.
+        """
+        if exclude is None:
+            exclude = ()
+        params = list(self._property_names_generator())
+        lines = [f"{self!r}"]
+        for key in list(dict.fromkeys(list(UNITS) + list(params))):
+            k = key
+            if not k.startswith("_") and k in params and k not in exclude:
+                unit = UNITS.get(k)
+                unit_str = f" {unit}" if unit else ""
+                val = ""
+                if k == "position":
+                    val = getattr(self, "_position", None)
+                    if isinstance(val, np.ndarray):
+                        if val.shape[0] != 1:
+                            lines.append(f"  • path length: {val.shape[0]}")
+                            k = f"{k} (last)"
+                        val = f"{val[-1]}"
+                elif k == "orientation":
+                    val = getattr(self, "_orientation", None)
+                    if isinstance(val, Rotation):
+                        val = val.as_rotvec(degrees=True)  # pylint: disable=no-member
+                        if len(val) != 1:
+                            k = f"{k} (last)"
+                        val = f"{val[-1]}"
+                elif k == "pixel":
+                    val = getattr(self, "pixel", None)
+                    if isinstance(val, np.ndarray):
+                        px_shape = val.shape[:-1]
+                        val_str = f"{int(np.prod(px_shape))}"
+                        if val.ndim > 2:
+                            val_str += f" ({'x'.join(str(p) for p in px_shape)})"
+                        val = val_str
+                elif k == "status_disconnected_data":
+                    val = getattr(self, k)
+                    if val is not None:
+                        val = f"{len(val)} part{'s'[: len(val) ^ 1]}"
+                elif isinstance(getattr(self, k), list | tuple | np.ndarray):
+                    val = np.array(getattr(self, k))
+                    if np.prod(val.shape) > 4:
+                        val = f"shape{val.shape}"
+                else:
+                    val = getattr(self, k)
+                lines.append(f"  • {k}: {val}{unit_str}")
+        return lines
+
+    def describe(self, *, exclude=("style", "field_func"), return_string=False):
+        """Returns a view of the object properties.
+
+        Parameters
+        ----------
+        exclude: bool, default=("style",)
+            Properties to be excluded in the description view.
+
+        return_string: bool, default=`False`
+            If `False` print description with stdout, if `True` return as string.
+        """
+        lines = self._get_description(exclude=exclude)
+        output = "\n".join(lines)
+
+        if return_string:
+            return output
+
+        print(output)  # noqa: T201
+        return None
+
+    def _repr_html_(self):
+        lines = self._get_description(exclude=("style", "field_func"))
+        return f"""<pre>{"<br>".join(lines)}</pre>"""
+
+    def __repr__(self) -> str:
+        name = getattr(self, "name", None)
+        if name is None:
+            style = getattr(self, "style", None)
+            name = getattr(style, "label", None)
+        name_str = "" if name is None else f", label={name!r}"
+        return f"{type(self).__name__}(id={id(self)!r}{name_str})"
diff --git a/src/magpylib/_src/obj_classes/class_BaseExcitations.py b/src/magpylib/_src/obj_classes/class_BaseExcitations.py
new file mode 100644
index 000000000..d4b8023d4
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_BaseExcitations.py
@@ -0,0 +1,489 @@
+"""BaseHomMag class code"""
+
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import warnings
+from typing import ClassVar
+
+import numpy as np
+
+from magpylib._src.exceptions import MagpylibDeprecationWarning
+from magpylib._src.fields.field_wrap_BH import getBH_level2
+from magpylib._src.input_checks import (
+    check_format_input_scalar,
+    check_format_input_vector,
+    validate_field_func,
+)
+from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr
+from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
+from magpylib._src.style import CurrentStyle, MagnetStyle
+from magpylib._src.utility import format_star_input
+
+
+class BaseSource(BaseGeo, BaseDisplayRepr):
+    """Base class for all types of sources. Provides getB and getH methods for source objects
+    and corresponding field function"""
+
+    _field_func = None
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {}
+    _editable_field_func = False
+
+    def __init__(self, position, orientation, field_func=None, style=None, **kwargs):
+        if field_func is not None:
+            self.field_func = field_func
+        BaseGeo.__init__(self, position, orientation, style=style, **kwargs)
+        BaseDisplayRepr.__init__(self)
+
+    @property
+    def field_func(self):
+        """
+        The function for B- and H-field computation must have the two positional arguments
+        `field` and `observers`. With `field='B'` or `field='H'` the B- or H-field in units
+        of T or A/m must be returned respectively. The `observers` argument must
+        accept numpy ndarray inputs of shape (n,3), in which case the returned fields must
+        be numpy ndarrays of shape (n,3) themselves.
+        """
+        return self._field_func
+
+    @field_func.setter
+    def field_func(self, val):
+        if self._editable_field_func:
+            validate_field_func(val)
+        else:
+            msg = "The `field_func` attribute should not be edited for original Magpylib sources."
+            raise AttributeError(msg)
+        self._field_func = val
+
+    def getB(
+        self, *observers, squeeze=True, pixel_agg=None, output="ndarray", in_out="auto"
+    ):
+        """Compute the B-field at observers in units of T generated by the source.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        observers: array_like or (list of) `Sensor` objects
+            Can be array_like positions of shape (n1, n2, ..., 3) where the field
+            should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+            of such sensor objects (must all have similar pixel shapes). All positions are given
+            in units of m.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi-
+            dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function
+            returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be
+            installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            B-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of T. Sensor pixel positions are equivalent
+            to simple observer positions. Paths of objects that are shorter than index m will be
+            considered as static beyond their end.
+
+        Examples
+        --------
+        Compute the B-field of a spherical magnet at three positions:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> src = magpy.magnet.Sphere(polarization=(0,0,1.), diameter=1)
+        >>> B = src.getB(((0,0,0), (1,0,0), (2,0,0)))
+        >>> with np.printoptions(precision=3):
+        ...     print(B)
+        [[ 0.     0.     0.667]
+         [ 0.     0.    -0.042]
+         [ 0.     0.    -0.005]]
+
+        Compute the B-field at two sensors, each one with two pixels
+
+        >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1)))
+        >>> sens2 = sens1.copy(position=(2,0,0))
+        >>> B = src.getB(sens1, sens2)
+        >>> with np.printoptions(precision=3):
+        ...     print(B)
+        [[[ 0.012  0.    -0.04 ]
+          [-0.012  0.    -0.04 ]]
+        <BLANKLINE>
+         [[ 0.001  0.    -0.005]
+          [-0.001  0.    -0.005]]]
+        """
+        observers = format_star_input(observers)
+        return getBH_level2(
+            self,
+            observers,
+            field="B",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getH(
+        self, *observers, squeeze=True, pixel_agg=None, output="ndarray", in_out="auto"
+    ):
+        """Compute the H-field in units of A/m at observers generated by the source.
+
+        Parameters
+        ----------
+        observers: array_like or (list of) `Sensor` objects
+            Can be array_like positions of shape (n1, n2, ..., 3) where the field
+            should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+            of such sensor objects (must all have similar pixel shapes). All positions
+            are given in units of m.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi-
+            dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function
+            returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be
+            installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            H-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of A/m. Sensor pixel positions are
+            equivalent to simple observer positions. Paths of objects that are shorter than
+            index m will be considered as static beyond their end.
+
+        Examples
+        --------
+        Compute the H-field of a spherical magnet at three positions:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+
+        >>> src = magpy.magnet.Sphere(polarization=(0,0,1.), diameter=1)
+        >>> H = src.getH(((0,0,0), (1,0,0), (2,0,0)))
+        >>> with np.printoptions(precision=0):
+        ...     print(H)
+        [[      0.       0. -265258.]
+         [      0.       0.  -33157.]
+         [      0.       0.   -4145.]]
+
+        Compute the H-field at two sensors, each one with two pixels
+
+        >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1)))
+        >>> sens2 = sens1.copy(position=(2,0,0))
+        >>> H = src.getH(sens1, sens2)
+        >>> with np.printoptions(precision=0):
+        ...     print(H)
+        [[[  9703.      0. -31696.]
+          [ -9703.      0. -31696.]]
+        <BLANKLINE>
+         [[   618.      0.  -4098.]
+          [  -618.      0.  -4098.]]]
+        """
+        observers = format_star_input(observers)
+        return getBH_level2(
+            self,
+            observers,
+            field="H",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getM(
+        self, *observers, squeeze=True, pixel_agg=None, output="ndarray", in_out="auto"
+    ):
+        """Compute the M-field in units of A/m at observers generated by the source.
+
+        Parameters
+        ----------
+        observers: array_like or (list of) `Sensor` objects
+            Can be array_like positions of shape (n1, n2, ..., 3) where the field
+            should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+            of such sensor objects (must all have similar pixel shapes). All positions
+            are given in units of m.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi-
+            dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function
+            returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be
+            installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        M-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            M-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of A/m. Sensor pixel positions are
+            equivalent to simple observer positions. Paths of objects that are shorter than
+            index m will be considered as static beyond their end.
+
+        Examples
+        --------
+        In this example we test the magnetization at an observer point.
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... ).rotate_from_angax(45,'z')
+        >>> M = cube.getM((3,3,0))
+        >>> with np.printoptions(precision=0):
+        ...    print(M)
+        [562698. 562698.      0.]
+        """
+        observers = format_star_input(observers)
+        return getBH_level2(
+            self,
+            observers,
+            field="M",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getJ(
+        self, *observers, squeeze=True, pixel_agg=None, output="ndarray", in_out="auto"
+    ):
+        """Compute the J-field at observers in units of T generated by the source.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        observers: array_like or (list of) `Sensor` objects
+            Can be array_like positions of shape (n1, n2, ..., 3) where the field
+            should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list
+            of such sensor objects (must all have similar pixel shapes). All positions are given
+            in units of m.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi-
+            dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function
+            returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be
+            installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        J-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            J-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of T. Sensor pixel positions are equivalent
+            to simple observer positions. Paths of objects that are shorter than index m will be
+            considered as static beyond their end.
+
+        Examples
+        --------
+        In this example we test the polarization at an observer point.
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... ).rotate_from_angax(45,'z')
+        >>> J = cube.getJ((3,3,0))
+        >>> with np.printoptions(precision=3):
+        ...    print(J)
+        [0.707 0.707 0.   ]
+        """
+        observers = format_star_input(observers)
+        return getBH_level2(
+            self,
+            observers,
+            field="J",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+
+class BaseMagnet(BaseSource):
+    """provides the magnetization and polarization attributes for magnet classes"""
+
+    _style_class = MagnetStyle
+
+    def __init__(
+        self, position, orientation, magnetization, polarization, style, **kwargs
+    ):
+        super().__init__(position, orientation, style=style, **kwargs)
+
+        self._polarization = None
+        self._magnetization = None
+        if magnetization is not None:
+            self.magnetization = magnetization
+            if polarization is not None:
+                msg = (
+                    "The attributes magnetization and polarization are dependent. "
+                    "Only one can be provided at magnet initialization."
+                )
+                raise ValueError(msg)
+        if polarization is not None:
+            self.polarization = polarization
+
+    @property
+    def magnetization(self):
+        """Object magnetization attribute getter and setter."""
+        return self._magnetization
+
+    @magnetization.setter
+    def magnetization(self, mag):
+        """Set magnetization vector, array_like, shape (3,), unit A/m."""
+        self._magnetization = check_format_input_vector(
+            mag,
+            dims=(1,),
+            shape_m1=3,
+            sig_name="magnetization",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,)",
+            allow_None=True,
+        )
+        self._polarization = self._magnetization * (4 * np.pi * 1e-7)
+        if np.linalg.norm(self._magnetization) < 2000:
+            self._magnetization_low_warning()
+
+    @property
+    def polarization(self):
+        """Object polarization attribute getter and setter."""
+        return self._polarization
+
+    @polarization.setter
+    def polarization(self, mag):
+        """Set polarization vector, array_like, shape (3,), unit T."""
+        self._polarization = check_format_input_vector(
+            mag,
+            dims=(1,),
+            shape_m1=3,
+            sig_name="polarization",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,)",
+            allow_None=True,
+        )
+        self._magnetization = self._polarization / (4 * np.pi * 1e-7)
+
+    def _magnetization_low_warning(self):
+        warnings.warn(
+            (
+                f"{self} received a very low magnetization. "
+                "In Magpylib v5 magnetization is given in units of A/m, "
+                "while polarization is given in units of T."
+            ),
+            MagpylibDeprecationWarning,
+            stacklevel=2,
+        )
+
+
+class BaseCurrent(BaseSource):
+    """provides scalar current attribute"""
+
+    _style_class = CurrentStyle
+
+    def __init__(self, position, orientation, current, style, **kwargs):
+        super().__init__(position, orientation, style=style, **kwargs)
+        self.current = current
+
+    @property
+    def current(self):
+        """Object current attribute getter and setter."""
+        return self._current
+
+    @current.setter
+    def current(self, current):
+        """Set current value, scalar, unit A."""
+        # input type and init check
+        self._current = check_format_input_scalar(
+            current,
+            sig_name="current",
+            sig_type="`None` or a number (int, float)",
+            allow_None=True,
+        )
diff --git a/src/magpylib/_src/obj_classes/class_BaseGeo.py b/src/magpylib/_src/obj_classes/class_BaseGeo.py
new file mode 100644
index 000000000..807e3ab72
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_BaseGeo.py
@@ -0,0 +1,380 @@
+"""BaseGeo class code"""
+
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-instance-attributes
+# pylint: disable=protected-access
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial.transform import Rotation as R
+
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.input_checks import (
+    check_format_input_orientation,
+    check_format_input_vector,
+)
+from magpylib._src.obj_classes.class_BaseTransform import BaseTransform
+from magpylib._src.style import BaseStyle
+from magpylib._src.utility import add_iteration_suffix
+
+
+def pad_slice_path(path1, path2):
+    """edge-pads or end-slices path 2 to fit path 1 format
+    path1: shape (N,x)
+    path2: shape (M,x)
+    return: path2 with format (N,x)
+    """
+    delta_path = len(path1) - len(path2)
+    if delta_path > 0:
+        return np.pad(path2, ((0, delta_path), (0, 0)), "edge")
+    if delta_path < 0:
+        return path2[-delta_path:]
+    return path2
+
+
+class BaseGeo(BaseTransform):
+    """Initializes position and orientation properties
+    of an object in a global CS.
+
+    position is a ndarray with shape (3,).
+
+    orientation is a scipy.spatial.transformation.Rotation
+    object that gives the relative rotation to the init_state. The
+    init_state is defined by how the fields are implemented (e.g.
+    cyl upright in xy-plane)
+
+    Both attributes _position and _orientation.as_rotvec() are of shape (N,3),
+    and describe a path of length N. (N=1 if there is only one
+    object position).
+
+    Properties
+    ----------
+    position: array_like, shape (N,3)
+        Position path
+
+    orientation: scipy.Rotation, shape (N,)
+        Rotation path
+
+    Methods
+    -------
+
+    - show
+    - move
+    - rotate
+
+    """
+
+    _style_class = BaseStyle
+
+    def __init__(
+        self,
+        position=(
+            0.0,
+            0.0,
+            0.0,
+        ),
+        orientation=None,
+        style=None,
+        **kwargs,
+    ):
+        self._style_kwargs = {}
+        self._parent = None
+        # set _position and _orientation attributes
+        self._init_position_orientation(position, orientation)
+
+        if style is not None or kwargs:  # avoid style creation cost if not needed
+            self._style_kwargs = self._process_style_kwargs(style=style, **kwargs)
+
+    @staticmethod
+    def _process_style_kwargs(style=None, **kwargs):
+        if kwargs:
+            if style is None:
+                style = {}
+            style_kwargs = {}
+            for k, v in kwargs.items():
+                if k.startswith("style_"):
+                    style_kwargs[k[6:]] = v
+                else:
+                    msg = f"__init__() got an unexpected keyword argument {k!r}"
+                    raise TypeError(msg)
+            style.update(**style_kwargs)
+        return style
+
+    def _init_position_orientation(self, position, orientation):
+        """tile up position and orientation input and set _position and
+        _orientation at class init. Because position and orientation inputs
+        come at the same time, tiling is slightly different then with setters.
+        pos: position input
+        ori: orientation input
+        """
+
+        # format position and orientation inputs
+        pos = check_format_input_vector(
+            position,
+            dims=(1, 2),
+            shape_m1=3,
+            sig_name="position",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+            reshape=(-1, 3),
+        )
+        oriQ = check_format_input_orientation(orientation, init_format=True)
+
+        # padding logic: if one is longer than the other, edge-pad up the other
+        len_pos = pos.shape[0]
+        len_ori = oriQ.shape[0]
+
+        if len_pos > len_ori:
+            oriQ = np.pad(oriQ, ((0, len_pos - len_ori), (0, 0)), "edge")
+        elif len_pos < len_ori:
+            pos = np.pad(pos, ((0, len_ori - len_pos), (0, 0)), "edge")
+
+        # set attributes
+        self._position = pos
+        self._orientation = R.from_quat(oriQ)
+
+    # properties ----------------------------------------------------
+    @property
+    def parent(self):
+        """The object is a child of it's parent collection."""
+        return self._parent
+
+    @parent.setter
+    def parent(self, inp):
+        # pylint: disable=import-outside-toplevel
+        from magpylib._src.obj_classes.class_Collection import Collection
+
+        if isinstance(inp, Collection):
+            inp.add(self, override_parent=True)
+        elif inp is None:
+            if self._parent is not None:
+                self._parent.remove(self)
+            self._parent = None
+        else:
+            msg = (
+                "Input `parent` must be `None` or a `Collection` object."
+                f"Instead received {type(inp)}."
+            )
+            raise MagpylibBadUserInput(msg)
+
+    @property
+    def position(self):
+        """
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+        """
+        return np.squeeze(self._position)
+
+    @position.setter
+    def position(self, inp):
+        """
+        Set object position-path.
+
+        Use edge-padding and end-slicing to adjust orientation path
+
+        When a Collection position is set, then all children retain their
+        relative position to the Collection BaseGeo.
+
+        position: array_like, shape (3,) or (N,3)
+            Position-path of object.
+        """
+        old_pos = self._position
+
+        # check and set new position
+        self._position = check_format_input_vector(
+            inp,
+            dims=(1, 2),
+            shape_m1=3,
+            sig_name="position",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+            reshape=(-1, 3),
+        )
+
+        # pad/slice and set orientation path to same length
+        oriQ = self._orientation.as_quat()
+        self._orientation = R.from_quat(pad_slice_path(self._position, oriQ))
+
+        # when there are children include their relative position
+        for child in getattr(self, "children", []):
+            old_pos = pad_slice_path(self._position, old_pos)
+            child_pos = pad_slice_path(self._position, child._position)
+            rel_child_pos = child_pos - old_pos
+            # set child position (pad/slice orientation)
+            child.position = self._position + rel_child_pos
+
+    @property
+    def orientation(self):
+        """
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+        """
+        # cannot squeeze (its a Rotation object)
+        if len(self._orientation) == 1:  # single path orientation - reduce dimension
+            return self._orientation[0]
+        return self._orientation  # return full path
+
+    @orientation.setter
+    def orientation(self, inp):
+        """Set object orientation-path.
+
+        inp: None or scipy Rotation, shape (1,) or (N,)
+            Set orientation-path of object. None generates a unit orientation
+            for every path step.
+        """
+        old_oriQ = self._orientation.as_quat()
+
+        # set _orientation attribute with ndim=2 format
+        oriQ = check_format_input_orientation(inp, init_format=True)
+        self._orientation = R.from_quat(oriQ)
+
+        # pad/slice position path to same length
+        self._position = pad_slice_path(oriQ, self._position)
+
+        # when there are children they rotate about self.position
+        # after the old Collection orientation is rotated away.
+        for child in getattr(self, "children", []):
+            # pad/slice and set child path
+            child.position = pad_slice_path(self._position, child._position)
+            # compute rotation and apply
+            old_ori_pad = R.from_quat(np.squeeze(pad_slice_path(oriQ, old_oriQ)))
+            child.rotate(
+                self.orientation * old_ori_pad.inv(), anchor=self._position, start=0
+            )
+
+    @property
+    def style(self):
+        """
+        Object style in the form of a BaseStyle object. Input must be
+        in the form of a style dictionary.
+        """
+        if getattr(self, "_style", None) is None:
+            self._style = self._style_class()
+        if self._style_kwargs:
+            style_kwargs = self._style_kwargs.copy()
+            self._style_kwargs = {}
+            try:
+                self._style.update(style_kwargs)
+            except (AttributeError, ValueError) as e:
+                e.args = (
+                    f"{self!r} has been initialized with some invalid style arguments.\n"
+                    + str(e),
+                )
+                raise
+        return self._style
+
+    @style.setter
+    def style(self, val):
+        self._style = self._validate_style(val)
+
+    def _validate_style(self, val=None):
+        val = {} if val is None else val
+        style = self.style  # triggers style creation
+        if isinstance(val, dict):
+            style.update(val)
+        elif not isinstance(val, self._style_class):
+            msg = (
+                f"Input parameter `style` must be of type {self._style_class}.\n"
+                f"Instead received type {type(val)}"
+            )
+            raise ValueError(msg)
+        return style
+
+    # dunders -------------------------------------------------------
+    def __add__(self, obj):
+        """Add up sources to a Collection object.
+
+        Returns
+        -------
+        Collection: Collection
+        """
+        # pylint: disable=import-outside-toplevel
+        from magpylib import Collection
+
+        return Collection(self, obj)
+
+    # methods -------------------------------------------------------
+    def reset_path(self):
+        """Set object position to (0,0,0) and orientation = unit rotation.
+
+        Returns
+        -------
+        self: magpylib object
+
+        Examples
+        --------
+        Demonstration of `reset_path` functionality:
+
+        >>> import magpylib as magpy
+        >>> obj = magpy.Sensor(position=(1,2,3))
+        >>> obj.rotate_from_angax(45, 'z')
+        Sensor...
+        >>> print(obj.position)
+        [1. 2. 3.]
+        >>> print(obj.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 45.]
+
+        >>> obj.reset_path()
+        Sensor(id=...)
+        >>> print(obj.position)
+        [0. 0. 0.]
+        >>> print(obj.orientation.as_euler('xyz', degrees=True))
+        [0. 0. 0.]
+        """
+        self.position = (0, 0, 0)
+        self.orientation = None
+        return self
+
+    def copy(self, **kwargs):
+        """Returns a copy of the current object instance. The `copy` method returns a deep copy of
+        the object, that is independent of the original object.
+
+        Parameters
+        ----------
+        kwargs: dict
+            Keyword arguments (for example `position=(1,2,3)`) are applied to the copy.
+
+        Examples
+        --------
+        Create a `Sensor` object and copy to an another position:
+
+        >>> import magpylib as magpy
+        >>> sens1 = magpy.Sensor(style_label='sens1')
+        >>> sens2 = sens1.copy(position=(2,6,10), style_label='sens2')
+        >>> print(f"Instance {sens1.style.label} with position {sens1.position}.")
+        Instance sens1 with position [0. 0. 0.].
+        >>> print(f"Instance {sens2.style.label} with position {sens2.position}.")
+        Instance sens2 with position [ 2.  6. 10.].
+        """
+        # pylint: disable=import-outside-toplevel
+        from copy import deepcopy
+
+        # avoid deepcopying the deep dependency upwards the tree structure
+        if self.parent is not None:
+            # using private attributes to avoid triggering `.add` method (see #530 bug)
+            parent = self._parent
+            self._parent = None
+            obj_copy = deepcopy(self)
+            self._parent = parent
+        else:
+            obj_copy = deepcopy(self)
+
+        if getattr(self, "_style", None) is not None or bool(
+            getattr(self, "_style_kwargs", False)
+        ):
+            # pylint: disable=no-member
+            label = self.style.label
+            if label is None:
+                label = f"{type(self).__name__}_01"
+            else:
+                label = add_iteration_suffix(label)
+            obj_copy.style.label = label
+        style_kwargs = {}
+        for k, v in kwargs.items():
+            if k.startswith("style"):
+                style_kwargs[k] = v
+            else:
+                setattr(obj_copy, k, v)
+        if style_kwargs:
+            style_kwargs = self._process_style_kwargs(**style_kwargs)
+            obj_copy.style.update(style_kwargs)
+        return obj_copy
diff --git a/src/magpylib/_src/obj_classes/class_BaseTransform.py b/src/magpylib/_src/obj_classes/class_BaseTransform.py
new file mode 100644
index 000000000..0bc49c89e
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_BaseTransform.py
@@ -0,0 +1,908 @@
+"""BaseTransform class code"""
+
+# pylint: disable=protected-access
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numbers
+
+import numpy as np
+from scipy.spatial.transform import Rotation as R
+
+from magpylib._src.input_checks import (
+    check_degree_type,
+    check_format_input_anchor,
+    check_format_input_angle,
+    check_format_input_axis,
+    check_format_input_orientation,
+    check_format_input_vector,
+    check_start_type,
+)
+
+
+def multi_anchor_behavior(anchor, inrotQ, rotation):
+    """define behavior of rotation with given anchor
+    if one is longer than the other pad up other
+    """
+    len_inrotQ = 0 if inrotQ.ndim == 1 else inrotQ.shape[0]
+    len_anchor = 0 if anchor.ndim == 1 else anchor.shape[0]
+
+    if len_inrotQ > len_anchor:
+        if len_anchor == 0:
+            anchor = np.reshape(anchor, (1, 3))
+            len_anchor = 1
+        anchor = np.pad(anchor, ((0, len_inrotQ - len_anchor), (0, 0)), "edge")
+    elif len_inrotQ < len_anchor:
+        if len_inrotQ == 0:
+            inrotQ = np.reshape(inrotQ, (1, 4))
+            len_inrotQ = 1
+        inrotQ = np.pad(inrotQ, ((0, len_anchor - len_inrotQ), (0, 0)), "edge")
+        rotation = R.from_quat(inrotQ)
+
+    return anchor, inrotQ, rotation
+
+
+def path_padding_param(scalar_input: bool, lenop: int, lenip: int, start: int):
+    """compute path padding parameters
+
+    Example: with start>0 and input path exceeds old_path
+        old_path:            |abcdefg|
+        input_path:              |xzyuvwrst|
+        -> padded_old_path:  |abcdefggggggg|
+
+    Parameters:
+    -----------
+    scalar_input: True if rotation input is scalar, else False
+    lenop: length of old_path
+    lenip: length of input_path
+    start: start index
+
+    Returns:
+    --------
+    padding: (pad_before, pad_behind)
+        how much the old_path must be padded before
+    start: modified start value
+    """
+    # initialize paddings
+    pad_before = 0
+    pad_behind = 0
+
+    # start='auto': apply to all if scalar, append if vector
+    if start == "auto":
+        start = 0 if scalar_input else lenop
+
+    # numpy convention with negative start indices
+    if start < 0:
+        start = lenop + start
+        # if start smaller than -old_path_length: pad before
+        if start < 0:
+            pad_before = -start  # pylint: disable=invalid-unary-operand-type
+            start = 0
+
+    # vector: if start+inpath extends beyond oldpath: pad behind
+    if start + lenip > lenop + pad_before:
+        pad_behind = start + lenip - (lenop + pad_before)
+
+    if pad_before + pad_behind > 0:
+        return (pad_before, pad_behind), start
+    return [], start
+
+
+def path_padding(inpath, start, target_object):
+    """pad path of target_object and compute start- and end-index for apply_move()
+    and apply_rotation() functions below so that ppath[start:end] = X... can be
+    applied.
+
+    Parameters
+    ----------
+    inpath: user input as np.ndarray
+    start: start index
+    target_object: Magpylib object with position and orientation attributes
+
+    Returns
+    -------
+    ppath: padded target_object position path
+    opath: padded target_object orientation path
+    start: modified start idex
+    end: end index
+    padded: True if padding was necessary, else False
+    """
+    # scalar or vector input
+    scalar_input = inpath.ndim == 1
+
+    # load old path
+    ppath = target_object._position
+    opath = target_object._orientation.as_quat()
+
+    lenip = 1 if scalar_input else len(inpath)
+
+    # pad old path depending on input
+    padding, start = path_padding_param(scalar_input, len(ppath), lenip, start)
+    if padding:
+        ppath = np.pad(ppath, (padding, (0, 0)), "edge")
+        opath = np.pad(opath, (padding, (0, 0)), "edge")
+
+    # set end-index
+    end = len(ppath) if scalar_input else start + lenip
+
+    return ppath, opath, start, end, bool(padding)
+
+
+def apply_move(target_object, displacement, start="auto"):
+    """Implementation of the move() functionality.
+
+    Parameters
+    ----------
+    target_object: object with position and orientation attributes
+    displacement: displacement vector/path, array_like, shape (3,) or (n,3).
+        If the input is scalar (shape (3,)) the operation is applied to the
+        whole path. If the input is a vector (shape (n,3)), it is
+        appended/merged with the existing path.
+    start: int, str, default=`'auto'`
+        start=i applies an operation starting at the i'th path index.
+        With start='auto' and scalar input the whole path is moved. With
+        start='auto' and vector input the input is appended.
+
+    Returns
+    -------
+    target_object
+    """
+    # pylint: disable=protected-access
+    # pylint: disable=attribute-defined-outside-init
+    # pylint: disable=too-many-branches
+
+    # check and format inputs
+    inpath = check_format_input_vector(
+        displacement,
+        dims=(1, 2),
+        shape_m1=3,
+        sig_name="displacement",
+        sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)",
+    )
+    check_start_type(start)
+
+    # pad target_object path and compute start and end-index for rotation application
+    ppath, opath, start, end, padded = path_padding(inpath, start, target_object)
+    if padded:
+        target_object._orientation = R.from_quat(opath)
+
+    # apply move operation
+    ppath[start:end] += inpath
+    target_object._position = ppath
+
+    return target_object
+
+
+def apply_rotation(
+    target_object, rotation: R, anchor=None, start="auto", parent_path=None
+):
+    """Implementation of the rotate() functionality.
+
+    Parameters
+    ----------
+    target_object: object with position and orientation attributes
+    rotation: a scipy Rotation object
+        If the input is scalar (shape (3,)) the operation is applied to the
+        whole path. If the input is a vector (shape (n,3)), it is
+        appended/merged with the existing path.
+    anchor: array_like shape (3,)
+        Rotation anchor
+    start: int, str, default=`'auto'`
+        start=i applies an operation starting at the i'th path index.
+        With start='auto' and scalar input the wole path is moved. With
+        start='auto' and vector input the input is appended.
+    parent_path=None if there is no parent else parent._position
+
+    Returns
+    -------
+    target_object
+    """
+    # pylint: disable=protected-access
+    # pylint: disable=too-many-branches
+
+    # check and format inputs
+    rotation, inrotQ = check_format_input_orientation(rotation)
+    anchor = check_format_input_anchor(anchor)
+    check_start_type(start)
+
+    # when an anchor is given
+    if anchor is not None:
+        # apply multi-anchor behavior
+        anchor, inrotQ, rotation = multi_anchor_behavior(anchor, inrotQ, rotation)
+
+    # pad target_object path and compute start and end-index for rotation application
+    ppath, opath, newstart, end, _ = path_padding(inrotQ, start, target_object)
+
+    # compute anchor when dealing with Compound rotation (target_object is a child
+    #   that rotates about its parent). This happens when a rotation with anchor=None
+    #   is applied to a child in a Collection. In this case the anchor must be set to
+    #   the parent_path.
+    if anchor is None and parent_path is not None:
+        # target anchor length
+        len_anchor = end - newstart
+        # pad up parent_path if input requires it
+        padding, start = path_padding_param(
+            inrotQ.ndim == 1, parent_path.shape[0], len_anchor, start
+        )
+        if padding:
+            parent_path = np.pad(parent_path, (padding, (0, 0)), "edge")
+        # slice anchor from padded parent_path
+        anchor = parent_path[start : start + len_anchor]
+
+    # position change when there is an anchor
+    if anchor is not None:
+        ppath[newstart:end] -= anchor
+        ppath[newstart:end] = rotation.apply(ppath[newstart:end])
+        ppath[newstart:end] += anchor
+
+    # set new rotation
+    oldrot = R.from_quat(opath[newstart:end])
+    opath[newstart:end] = (rotation * oldrot).as_quat()
+
+    # store new position and orientation
+    # pylint: disable=attribute-defined-outside-init
+    target_object._orientation = R.from_quat(opath)
+    target_object._position = ppath
+
+    return target_object
+
+
+class BaseTransform:
+    """Inherit this class to provide rotation() and move() methods."""
+
+    def move(self, displacement, start="auto"):
+        """Move object by the displacement input. SI units are used for all inputs and outputs.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        displacement: array_like, shape (3,) or (n,3)
+            Displacement vector in units of m.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+
+        Move objects around with scalar input:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,1,1))
+        >>> print(sens.position)
+        [1. 1. 1.]
+        >>> sens.move((1,1,1))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [2. 2. 2.]
+
+        Create len>1 object paths with vector input:
+
+        >>> sens.move([(1,1,1),(2,2,2),(3,3,3)])
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[2. 2. 2.]
+         [3. 3. 3.]
+         [4. 4. 4.]
+         [5. 5. 5.]]
+
+        Apply operations starting with a designated path index:
+
+        >>> sens.move((0,0,2), start=2)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[2. 2. 2.]
+         [3. 3. 3.]
+         [4. 4. 6.]
+         [5. 5. 7.]]
+        """
+
+        # Idea: An operation applied to a Collection is individually
+        #    applied to its BaseGeo and to each child.
+
+        for child in getattr(self, "children", []):
+            child.move(displacement, start=start)
+
+        apply_move(self, displacement, start=start)
+
+        return self
+
+    def _rotate(self, rotation: R, anchor=None, start="auto", parent_path=None):
+        """Rotate object about a given anchor.
+
+        See `rotate` docstring for other parameters.
+
+        Parameters
+        ----------
+        parent_path: if there is no parent else parent._position
+            needs to be transmitted from the top level for nested collections, hence using a
+            private `_rotate` method to do so.
+
+        """
+        # Idea: An operation applied to a Collection is individually
+        #    applied to its BaseGeo and to each child.
+        #  -> this automatically generates the rotate-Compound behavior
+
+        # pylint: disable=no-member
+        for child in getattr(self, "children", []):
+            ppth = self._position if parent_path is None else parent_path
+            child._rotate(rotation, anchor=anchor, start=start, parent_path=ppth)
+
+        apply_rotation(
+            self, rotation, anchor=anchor, start=start, parent_path=parent_path
+        )
+        return self
+
+    def rotate(self, rotation: R, anchor=None, start="auto"):
+        """Rotate object about a given anchor.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        rotation: `None` or scipy `Rotation` object
+            Rotation to be applied to the object. The scipy `Rotation` input can
+            be scalar or vector type (see terminology above). `None` input is interpreted
+            as unit rotation.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+
+        Rotate an object about the origin:
+
+        >>> from scipy.spatial.transform import Rotation as R
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate(R.from_euler('z', 45, degrees=True), anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 45.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate(R.from_euler('z', 45, degrees=True))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Create a rotation path by rotating in several steps about an anchor:
+
+        >>> sens.rotate(R.from_euler('z', (15,30,45), degrees=True), anchor=(0,0,0))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[ 7.07106781e-01  7.07106781e-01  0.00000000e+00]
+         [ 5.00000000e-01  8.66025404e-01  0.00000000e+00]
+         [ 2.58819045e-01  9.65925826e-01  0.00000000e+00]
+         [-2.22044605e-16  1.00000000e+00  0.00000000e+00]]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [[  0.   0.  90.]
+         [  0.   0. 105.]
+         [  0.   0. 120.]
+         [  0.   0. 135.]]
+        """
+
+        return self._rotate(rotation=rotation, anchor=anchor, start=start)
+
+    def rotate_from_angax(self, angle, axis, anchor=None, start="auto", degrees=True):
+        """Rotates object using angle-axis input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        angle: int, float or array_like with shape (n,)
+            Angle(s) of rotation in units of deg (by default).
+
+        axis: str or array_like, shape (3,)
+            The direction of the axis of rotation. Input can be a vector of shape (3,)
+            or a string 'x', 'y' or 'z' to denote respective directions.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        degrees: bool, default=`True`
+            Interpret input in units of deg or rad.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_angax(45, axis='z', anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 45.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_angax(45, axis=(0,0,1))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Create a rotation path by rotating in several steps about an anchor:
+
+        >>> sens.rotate_from_angax((15,30,45), axis='z', anchor=(0,0,0))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[ 7.07106781e-01  7.07106781e-01  0.00000000e+00]
+         [ 5.00000000e-01  8.66025404e-01  0.00000000e+00]
+         [ 2.58819045e-01  9.65925826e-01  0.00000000e+00]
+         [-2.22044605e-16  1.00000000e+00  0.00000000e+00]]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [[  0.   0.  90.]
+         [  0.   0. 105.]
+         [  0.   0. 120.]
+         [  0.   0. 135.]]
+        """
+        # check/format inputs
+        angle = check_format_input_angle(angle)
+        axis = check_format_input_axis(axis)
+        check_start_type(start)
+        check_degree_type(degrees)
+
+        # degree to rad
+        if degrees:
+            angle = angle / 180 * np.pi
+
+        # create rotation vector from angle/axis input
+        if isinstance(angle, numbers.Number):
+            angle = np.ones(3) * angle
+        else:
+            angle = np.tile(angle, (3, 1)).T
+        axis = axis / np.linalg.norm(axis) * angle
+
+        # forwards rotation object to rotate method
+        rot = R.from_rotvec(axis)
+        return self.rotate(rot, anchor, start)
+
+    def rotate_from_rotvec(self, rotvec, anchor=None, start="auto", degrees=True):
+        """Rotates object using rotation vector input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        rotvec : array_like, shape (n,3) or (3,)
+            Rotation input. Rotation vector direction is the rotation axis, vector length is
+            the rotation angle in units of rad.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        degrees: bool, default=`True`
+            Interpret input in units of deg or rad.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_rotvec((0,0,45), anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 45.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_rotvec((0,0,45))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Create a rotation path by rotating in several steps about an anchor:
+
+        >>> sens.rotate_from_rotvec([(0,0,15), (0,0,30), (0,0,45)], anchor=(0,0,0))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[ 7.07106781e-01  7.07106781e-01  0.00000000e+00]
+         [ 5.00000000e-01  8.66025404e-01  0.00000000e+00]
+         [ 2.58819045e-01  9.65925826e-01  0.00000000e+00]
+         [-2.22044605e-16  1.00000000e+00  0.00000000e+00]]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [[  0.   0.  90.]
+         [  0.   0. 105.]
+         [  0.   0. 120.]
+         [  0.   0. 135.]]
+        """
+        rot = R.from_rotvec(rotvec, degrees=degrees)
+        return self.rotate(rot, anchor=anchor, start=start)
+
+    def rotate_from_euler(self, angle, seq, anchor=None, start="auto", degrees=True):
+        """Rotates object using Euler angle input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        angle: int, float or array_like with shape (n,)
+            Angle(s) of rotation in units of deg (by default).
+
+        seq : string
+            Specifies sequence of axes for rotations. Up to 3 characters
+            belonging to the set {'X', 'Y', 'Z'} for intrinsic rotations, or
+            {'x', 'y', 'z'} for extrinsic rotations. Extrinsic and intrinsic
+            rotations cannot be mixed in one function call.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        degrees: bool, default=`True`
+            Interpret input in units of deg or rad.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_euler(45, 'z', anchor=0)
+        Sensor...
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 45.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_euler(45, 'z')
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0.70710678 0.70710678 0.        ]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Create a rotation path by rotating in several steps about an anchor:
+
+        >>> sens.rotate_from_euler((15,30,45), 'z', anchor=(0,0,0))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [[ 7.07106781e-01  7.07106781e-01  0.00000000e+00]
+         [ 5.00000000e-01  8.66025404e-01  0.00000000e+00]
+         [ 2.58819045e-01  9.65925826e-01  0.00000000e+00]
+         [-2.22044605e-16  1.00000000e+00  0.00000000e+00]]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [[  0.   0.  90.]
+         [  0.   0. 105.]
+         [  0.   0. 120.]
+         [  0.   0. 135.]]
+        """
+        rot = R.from_euler(seq, angle, degrees=degrees)
+        return self.rotate(rot, anchor=anchor, start=start)
+
+    def rotate_from_matrix(self, matrix, anchor=None, start="auto"):
+        """Rotates object using matrix input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+
+        Parameters
+        ----------
+        matrix : array_like, shape (n,3,3) or (3,3)
+            Rotation input. See scipy.spatial.transform.Rotation for details.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_matrix([(0,-1,0),(1,0,0),(0,0,1)], anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0. 1. 0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_matrix([(0,-1,0),(1,0,0),(0,0,1)])
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0. 1. 0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [  0.   0. 180.]
+        """
+        rot = R.from_matrix(matrix)
+        return self.rotate(rot, anchor=anchor, start=start)
+
+    def rotate_from_mrp(self, mrp, anchor=None, start="auto"):
+        """Rotates object using Modified Rodrigues Parameters (MRPs) input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        mrp : array_like, shape (n,3) or (3,)
+            Rotation input. See scipy Rotation package for details on Modified Rodrigues
+            Parameters (MRPs).
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_mrp((0,0,1), anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [-1.  0.  0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [  0.   0. 180.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_matrix([(0,-1,0),(1,0,0),(0,0,1)])
+        Sensor(id=...)
+        >>> print(sens.position)
+        [-1.  0.  0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [  0.   0. -90.]
+        """
+        rot = R.from_mrp(mrp)
+        return self.rotate(rot, anchor=anchor, start=start)
+
+    def rotate_from_quat(self, quat, anchor=None, start="auto"):
+        """Rotates object using quaternion input.
+
+        Terminology for move/rotate methods:
+
+        - `path` refers to `position` and `orientation` of an object.
+        - When an input is just a single operation (e.g. one displacement vector or one angle)
+          we call it 'scalar input'. When it is an array_like of multiple scalars, we refer to
+          it as 'vector input'.
+
+        General move/rotate behavior:
+
+        - Scalar input is applied to the whole object path, starting with path index `start`.
+        - Vector input of length n applies the individual n operations to n object path
+          entries, starting with path index `start`.
+        - When an input extends beyond the object path, the object path will be padded by its
+          edge-entries before the operation is applied.
+        - By default (`start='auto'`) the index is set to `start=0` for scalar input [=move
+          whole object path], and to `start=len(object path)` for vector input [=append to
+          existing object path].
+
+        Parameters
+        ----------
+        quat : array_like, shape (n,4) or (4,)
+            Rotation input in quaternion form.
+
+        anchor: `None`, `0` or array_like with shape (3,) or (n,3), default=`None`
+            The axis of rotation passes through the anchor point given in units of m.
+            By default (`anchor=None`) the object will rotate about its own center.
+            `anchor=0` rotates the object about the origin `(0,0,0)`.
+
+        start: int or str, default=`'auto'`
+            Starting index when applying operations. See 'General move/rotate behavior' above
+            for details.
+
+        Returns
+        -------
+        self: Magpylib object
+
+        Examples
+        --------
+        Rotate an object about the origin:
+
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor(position=(1,0,0))
+        >>> sens.rotate_from_quat((0,0,1,1), anchor=0)
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0. 1. 0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [ 0.  0. 90.]
+
+        Rotate the object about itself:
+
+        >>> sens.rotate_from_quat((0,0,1,1))
+        Sensor(id=...)
+        >>> print(sens.position)
+        [0. 1. 0.]
+        >>> print(sens.orientation.as_euler('xyz', degrees=True))
+        [  0.   0. 180.]
+        """
+        rot = R.from_quat(quat)
+        return self.rotate(rot, anchor=anchor, start=start)
diff --git a/src/magpylib/_src/obj_classes/class_Collection.py b/src/magpylib/_src/obj_classes/class_Collection.py
new file mode 100644
index 000000000..bcc491d8f
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_Collection.py
@@ -0,0 +1,958 @@
+"""Collection class code"""
+
+# pylint: disable=redefined-builtin
+# pylint: disable=import-outside-toplevel
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+from collections import Counter
+
+from magpylib._src.defaults.defaults_utility import validate_style_keys
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.fields.field_wrap_BH import getBH_level2
+from magpylib._src.input_checks import check_format_input_obj
+from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr
+from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
+from magpylib._src.utility import format_obj_input, rec_obj_remover
+
+
+def repr_obj(obj, format="type+id+label"):
+    """
+    Returns a string that describes the object depending on the chosen tag format.
+    """
+    # pylint: disable=protected-access
+    show_type = "type" in format
+    show_label = "label" in format
+    show_id = "id" in format
+
+    tag = ""
+    if show_type:
+        tag += f"{type(obj).__name__}"
+
+    if show_label:
+        if show_type:
+            tag += " "
+        label = getattr(getattr(obj, "style", None), "label", None)
+        if label is None:
+            label = "nolabel" if show_type else f"{type(obj).__name__}"
+        tag += label
+
+    if show_id:
+        if show_type or show_label:
+            tag += " "
+        tag += f"(id={id(obj)})"
+    return tag
+
+
+def collection_tree_generator(
+    obj,
+    format="type+id+label",
+    max_elems=20,
+    prefix="",
+    space="    ",
+    branch="│   ",
+    tee="├── ",
+    last="└── ",
+):
+    """
+    Recursively creates a generator that will yield a visual tree structure of
+    a collection object and all its children.
+    """
+    # pylint: disable=protected-access
+
+    # store children and properties of this branch
+    contents = []
+
+    children = getattr(obj, "children", [])
+    if len(children) > max_elems:  # replace with counter if too many
+        counts = Counter([type(c).__name__ for c in children])
+        children = [f"{v}x {k}s" for k, v in counts.items()]
+
+    props = []
+    view_props = "properties" in format
+    if view_props:
+        desc = getattr(obj, "_get_description", False)
+        if desc:
+            desc_out = desc(
+                exclude=(
+                    "children",
+                    "parent",
+                    "style",
+                    "field_func",
+                    "sources",
+                    "sensors",
+                    "collections",
+                    "children_all",
+                    "sources_all",
+                    "sensors_all",
+                    "collections_all",
+                )
+            )
+            props = [d.strip() for d in desc_out[1:]]
+
+    contents.extend(props)
+    contents.extend(children)
+
+    # generate and store "pointer" structure for this branch
+    pointers = [tee] * (len(contents) - 1) + [last]
+    pointers[: len(props)] = [branch if children else space] * len(props)
+
+    # create branch entries
+    for pointer, child in zip(pointers, contents, strict=False):
+        child_repr = child if isinstance(child, str) else repr_obj(child, format)
+        yield prefix + pointer + child_repr
+
+        # recursion
+        has_child = getattr(child, "children", False)
+        if has_child or (view_props and desc):
+            # space because last, └── , above so no more |
+            extension = branch if pointer == tee else space
+
+            yield from collection_tree_generator(
+                child,
+                format=format,
+                max_elems=max_elems,
+                prefix=prefix + extension,
+                space=space,
+                branch=branch,
+                tee=tee,
+                last=last,
+            )
+
+
+class BaseCollection(BaseDisplayRepr):
+    """Collection base class without BaseGeo properties"""
+
+    get_trace = None
+
+    def __init__(self, *children, override_parent=False):
+        BaseDisplayRepr.__init__(self)
+
+        self._children = []
+        self._sources = []
+        self._sensors = []
+        self._collections = []
+        self.add(*children, override_parent=override_parent)
+
+    # property getters and setters
+    @property
+    def children(self):
+        """An ordered list of top level child objects."""
+        return self._children
+
+    @children.setter
+    def children(self, children):
+        """Set Collection children."""
+        # pylint: disable=protected-access
+        for child in self._children:
+            child._parent = None
+        self._children = []
+        self.add(*children, override_parent=True)
+
+    @property
+    def children_all(self):
+        """An ordered list of all child objects in the collection tree."""
+        return check_format_input_obj(self, "collections+sensors+sources")
+
+    @property
+    def sources(self):
+        """An ordered list of top level source objects."""
+        return self._sources
+
+    @sources.setter
+    def sources(self, sources):
+        """Set Collection sources."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._sources:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
+        src_list = format_obj_input(sources, allow="sources")
+        self.add(*src_list, override_parent=True)
+
+    @property
+    def sources_all(self):
+        """An ordered list of all source objects in the collection tree."""
+        return check_format_input_obj(self, "sources")
+
+    @property
+    def sensors(self):
+        """An ordered list of top level sensor objects."""
+        return self._sensors
+
+    @sensors.setter
+    def sensors(self, sensors):
+        """Set Collection sensors."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._sensors:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
+        sens_list = format_obj_input(sensors, allow="sensors")
+        self.add(*sens_list, override_parent=True)
+
+    @property
+    def sensors_all(self):
+        """An ordered list of all sensor objects in the collection tree."""
+        return check_format_input_obj(self, "sensors")
+
+    @property
+    def collections(self):
+        """An ordered list of top level collection objects."""
+        return self._collections
+
+    @collections.setter
+    def collections(self, collections):
+        """Set Collection collections."""
+        # pylint: disable=protected-access
+        new_children = []
+        for child in self._children:
+            if child in self._collections:
+                child._parent = None
+            else:
+                new_children.append(child)
+        self._children = new_children
+        coll_list = format_obj_input(collections, allow="collections")
+        self.add(*coll_list, override_parent=True)
+
+    @property
+    def collections_all(self):
+        """An ordered list of all collection objects in the collection tree."""
+        return check_format_input_obj(self, "collections")
+
+    # dunders
+    def __iter__(self):
+        yield from self._children
+
+    def __getitem__(self, i):
+        return self._children[i]
+
+    def __len__(self):
+        return len(self._children)
+
+    def _repr_html_(self):
+        lines = []
+        lines.append(repr_obj(self))
+        for line in collection_tree_generator(
+            self,
+            format="type+label+id",
+            max_elems=10,
+        ):
+            lines.append(line)
+        return f"""<pre>{"<br>".join(lines)}</pre>"""
+
+    def describe(self, format="type+label+id", max_elems=10, return_string=False):
+        # pylint: disable=arguments-differ
+        """Returns or prints a tree view of the collection.
+
+        Parameters
+        ----------
+        format: bool, default='type+label+id'
+            Object description in tree view. Can be any combination of `'type'`, `'label'`
+            and `'id'` and `'properties'`.
+        max_elems: default=10
+            If number of children at any level is higher than `max_elems`, elements are
+            replaced by counters.
+        return_string: bool, default=`False`
+            If `False` print description with stdout, if `True` return as string.
+        """
+        tree = collection_tree_generator(
+            self,
+            format=format,
+            max_elems=max_elems,
+        )
+        output = [repr_obj(self, format)]
+        for t in tree:
+            output.append(t)
+        output = "\n".join(output)
+
+        if return_string:
+            return output
+        print(output)  # noqa: T201
+        return None
+
+    # methods -------------------------------------------------------
+    def add(self, *children, override_parent=False):
+        """Add sources, sensors or collections.
+
+        Parameters
+        ----------
+        children: sources, sensors or collections
+            Add arbitrary sources, sensors or other collections to this collection.
+
+        override_parent: bool, default=`True`
+            Accept objects as children that already have parents. Automatically
+            removes such objects from previous parent collection.
+
+        Returns
+        -------
+        self: `Collection` object
+
+        Examples
+        --------
+        In this example we add a sensor object to a collection:
+
+        >>> import magpylib as magpy
+        >>> x1 = magpy.Sensor(style_label='x1')
+        >>> coll = magpy.Collection(x1, style_label='coll')
+        >>> coll.describe(format='label')
+        coll
+        └── x1
+
+        >>> x2 = magpy.Sensor(style_label='x2')
+        >>> coll.add(x2)
+        Collection(id=...)
+        >>> coll.describe(format='label')
+        coll
+        ├── x1
+        └── x2
+        """
+        # pylint: disable=protected-access
+
+        # allow flat lists as input
+        if len(children) == 1 and isinstance(children[0], list | tuple):
+            children = children[0]
+
+        # check and format input
+        obj_list = check_format_input_obj(
+            children,
+            allow="sensors+sources+collections",
+            recursive=False,
+            typechecks=True,
+        )
+
+        # assign parent
+        for obj in obj_list:
+            # no need to check recursively with `collections_all` if obj is already self
+            if isinstance(obj, Collection) and (
+                obj is self or self in obj.collections_all
+            ):
+                msg = f"Cannot add {obj!r} because a Collection must not reference itself."
+                raise MagpylibBadUserInput(msg)
+            if obj._parent is None:
+                obj._parent = self
+            elif override_parent:
+                obj._parent.remove(obj)
+                obj._parent = self
+            else:
+                msg = (
+                    f"Cannot add {obj!r} to {self!r} because it already has a parent.\n"
+                    "Consider using `override_parent=True`."
+                )
+                raise MagpylibBadUserInput(msg)
+
+        # set attributes
+        self._children += obj_list
+        self._update_src_and_sens()
+
+        return self
+
+    def _update_src_and_sens(self):
+        """updates sources, sensors and collections attributes from children"""
+        # pylint: disable=protected-access
+        from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+        from magpylib._src.obj_classes.class_Sensor import Sensor
+
+        self._sources = [obj for obj in self._children if isinstance(obj, BaseSource)]
+        self._sensors = [obj for obj in self._children if isinstance(obj, Sensor)]
+        self._collections = [
+            obj for obj in self._children if isinstance(obj, Collection)
+        ]
+
+    def remove(self, *children, recursive=True, errors="raise"):
+        """Remove children from the collection tree.
+
+        Parameters
+        ----------
+        children: child objects
+            Remove the given children from the collection.
+
+        recursive: bool, default=`True`
+            Remove children also when they are in child collections.
+
+        errors: str, default=`'raise'`
+            Can be `'raise'` or `'ignore'` to toggle error output when child is
+            not found for removal.
+
+        Returns
+        -------
+        self: `Collection` object
+
+        Examples
+        --------
+        In this example we remove a child from a Collection:
+
+        >>> import magpylib as magpy
+        >>> x1 = magpy.Sensor(style_label='x1')
+        >>> x2 = magpy.Sensor(style_label='x2')
+        >>> col = magpy.Collection(x1, x2, style_label='col')
+        >>> col.describe(format='label')
+        col
+        ├── x1
+        └── x2
+
+        >>> col.remove(x1)
+        Collection(id=...)
+        >>> col.describe(format='label')
+        col
+        └── x2
+        """
+        # pylint: disable=protected-access
+
+        # allow flat lists as input
+        if len(children) == 1 and isinstance(children[0], list | tuple):
+            children = children[0]
+
+        # check and format input
+        remove_objects = check_format_input_obj(
+            children,
+            allow="sensors+sources+collections",
+            recursive=False,
+            typechecks=True,
+        )
+        self_objects = check_format_input_obj(
+            self,
+            allow="sensors+sources+collections",
+            recursive=recursive,
+        )
+        for child in remove_objects:
+            if child in self_objects:
+                rec_obj_remover(self, child)
+                child._parent = None
+            else:
+                if errors == "raise":
+                    msg = f"Cannot find and remove {child} from {self}."
+                    raise MagpylibBadUserInput(msg)
+                if errors != "ignore":
+                    msg = (
+                        "Input `errors` must be one of ('raise', 'ignore').\n"
+                        f"Instead received {errors}."
+                    )
+                    raise MagpylibBadUserInput(msg)
+        return self
+
+    def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs):
+        """Set display style of all children in the collection. Only matching properties
+        will be applied.
+
+        Parameters
+        ----------
+        arg: style dictionary or style underscore magic input
+            Style arguments to be applied.
+
+        recursive: bool, default=`True`
+            Apply styles also to children of child collections.
+
+        Returns
+        -------
+        self: `Collection` object
+
+        Examples
+        --------
+        In this example we start by creating a collection from three sphere magnets:
+
+        >>> import magpylib as magpy
+        >>>
+        >>> col = magpy.Collection(
+        ...     [
+        ...         magpy.magnet.Sphere(position=(i, 0, 0), diameter=1, polarization=(0, 0, 0.1))
+        ...         for i in range(3)
+        ...     ]
+        ... )
+        >>> # We apply styles using underscore magic for magnetization vector size and a style
+        >>> # dictionary for the color.
+        >>>
+        >>> col.set_children_styles(magnetization_size=0.5)
+        Collection(id=...)
+        >>> col.set_children_styles({"color": "g"})
+        Collection(id=...)
+        >>>
+        >>> # Finally we create a separate sphere magnet to demonstrate the default style
+        >>> # the collection and the separate magnet with Matplotlib:
+        >>>
+        >>> src = magpy.magnet.Sphere(position=(3, 0, 0), diameter=1, polarization=(0, 0, .1))
+        >>> magpy.show(col, src) # doctest: +SKIP
+        >>> # graphic output
+        """
+        # pylint: disable=protected-access
+
+        if arg is None:
+            arg = {}
+        if kwargs:
+            arg.update(kwargs)
+        style_kwargs = arg
+        if _validate:
+            style_kwargs = validate_style_keys(arg)
+
+        for child in self._children:
+            # match properties false will try to apply properties from kwargs only if it finds it
+            # without throwing an error
+            if isinstance(child, Collection) and recursive:
+                self.__class__.set_children_styles(child, style_kwargs, _validate=False)
+            style_kwargs_specific = {
+                k: v
+                for k, v in style_kwargs.items()
+                if k.split("_")[0] in child.style.as_dict()
+            }
+            child.style.update(**style_kwargs_specific, _match_properties=True)
+        return self
+
+    def _validate_getBH_inputs(self, *inputs):
+        """
+        select correct sources and observers for getBHJM_level2
+        """
+        # pylint: disable=protected-access
+        # pylint: disable=too-many-branches
+        # pylint: disable=possibly-used-before-assignment
+        current_sources = format_obj_input(self, allow="sources")
+        current_sensors = format_obj_input(self, allow="sensors")
+
+        # if collection includes source and observer objects, select itself as
+        #   source and observer in gethBH
+        if current_sensors and current_sources:
+            sources, sensors = self, self
+            if inputs:
+                msg = (
+                    "Collections with sensors and sources do not allow `collection.getB()` inputs."
+                    "Consider using `magpy.getB()` instead."
+                )
+                raise MagpylibBadUserInput(msg)
+        # if collection has no sources, *inputs must be the sources
+        elif not current_sources:
+            sources, sensors = inputs, self
+
+        # if collection has no sensors, *inputs must be the observers
+        elif not current_sensors:
+            if len(inputs) == 1:
+                sources, sensors = self, inputs[0]
+            else:
+                sources, sensors = self, inputs
+
+        return sources, sensors
+
+    def getB(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"):
+        """Compute B-field for given sources and observers.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        inputs: source or observer objects
+            Input can only be observers if the collection contains only sources. In this case the
+            collection behaves like a single source.
+            Input can only be sources if the collection contains only sensors. In this case the
+            collection behaves like a list of all its sensors.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        Returns
+        -------
+        B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            B-field at each path position ( index m) for each sensor (index k) and each
+            sensor pixel position (indices n1,n2,...) in units of T. Sensor pixel positions
+            are equivalent to simple observer positions. Paths of objects that are shorter
+            than index m are considered as static beyond their end.
+
+        Examples
+        --------
+        In this example we create a collection from two sources and two sensors:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> # Create Collection with two sensors and two magnets
+        >>> src1 = magpy.magnet.Sphere(polarization=(0,0,1.), diameter=1)
+        >>> src2 = src1.copy()
+        >>> sens1 = magpy.Sensor(position=(0,0,.6))
+        >>> sens2 = sens1.copy()
+        >>> col = src1 + src2 + sens1 + sens2
+        >>> # The following computations al give the same result
+        >>> B = col.getB()
+        >>> B = magpy.getB(col, col)
+        >>> B = magpy.getB(col, [sens1, sens2])
+        >>> B = magpy.getB([src1, src2], col)
+        >>> B = magpy.getB([src1, src2], [sens1, sens2])
+        >>> with np.printoptions(precision=3):
+        ...     print(B)
+        [[[0.    0.    0.386]
+          [0.    0.    0.386]]
+        <BLANKLINE>
+         [[0.    0.    0.386]
+          [0.    0.    0.386]]]
+        """
+
+        sources, sensors = self._validate_getBH_inputs(*inputs)
+        return getBH_level2(
+            sources,
+            sensors,
+            field="B",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out="auto",
+        )
+
+    def getH(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"):
+        """Compute H-field for given sources and observers.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        inputs: source or observer objects
+            Input can only be observers if the collection contains only sources. In this case the
+            collection behaves like a single source.
+            Input can only be sources if the collection contains sensors. In this case the
+            collection behaves like a list of all its sensors.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        Returns
+        -------
+        H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            H-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of A/m. Sensor pixel positions are
+            equivalent to simple observer positions. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        In this example we create a collection from two sources and two sensors:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> # Create Collection with two sensors and two magnets
+        >>> src1 = magpy.magnet.Sphere(polarization=(0,0,1.), diameter=1)
+        >>> src2 = src1.copy()
+        >>> sens1 = magpy.Sensor(position=(0,0,1))
+        >>> sens2 = sens1.copy()
+        >>> col = src1 + src2 + sens1 + sens2
+        >>> # The following computations al give the same result
+        >>> H = col.getH()
+        >>> H = magpy.getH(col, col)
+        >>> H = magpy.getH(col, [sens1, sens2])
+        >>> H = magpy.getH([src1, src2], col)
+        >>> H = magpy.getH([src1, src2], [sens1, sens2])
+        >>> with np.printoptions(precision=3):
+        ...    print(H)
+        [[[    0.       0.   66314.56]
+          [    0.       0.   66314.56]]
+        <BLANKLINE>
+         [[    0.       0.   66314.56]
+          [    0.       0.   66314.56]]]
+        """
+
+        sources, sensors = self._validate_getBH_inputs(*inputs)
+
+        return getBH_level2(
+            sources,
+            sensors,
+            field="H",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out="auto",
+        )
+
+    def getM(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"):
+        """Compute M-field for given sources and observers.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        inputs: source or observer objects
+            Input can only be observers if the collection contains only sources. In this case the
+            collection behaves like a single source.
+            Input can only be sources if the collection contains sensors. In this case the
+            collection behaves like a list of all its sensors.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        Returns
+        -------
+        M-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            M-field at each path position (index m) for each sensor (index k) and each sensor
+            pixel position (indices n1,n2,...) in units of A/m. Sensor pixel positions are
+            equivalent to simple observer positions. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... ).rotate_from_angax(45,'z')
+        >>> coll = magpy.Collection(cube)
+        >>> M = coll.getM((3,3,0))
+        >>> with np.printoptions(precision=0):
+        ...    print(M)
+        [562698. 562698.      0.]
+        """
+
+        sources, sensors = self._validate_getBH_inputs(*inputs)
+
+        return getBH_level2(
+            sources,
+            sensors,
+            field="M",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out="auto",
+        )
+
+    def getJ(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"):
+        """Compute J-field for given sources and observers.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        inputs: source or observer objects
+            Input can only be observers if the collection contains only sources. In this case the
+            collection behaves like a single source.
+            Input can only be sources if the collection contains only sensors. In this case the
+            collection behaves like a list of all its sensors.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g.
+            only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        Returns
+        -------
+        J-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame
+            J-field at each path position ( index m) for each sensor (index k) and each
+            sensor pixel position (indices n1,n2,...) in units of T. Sensor pixel positions
+            are equivalent to simple observer positions. Paths of objects that are shorter
+            than index m are considered as static beyond their end.
+
+        Examples
+        --------
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... ).rotate_from_angax(45,'z')
+        >>> coll = magpy.Collection(cube)
+        >>> J = coll.getJ((3,3,0))
+        >>> with np.printoptions(precision=3):
+        ...    print(J)
+        [0.707 0.707 0.   ]
+        """
+
+        sources, sensors = self._validate_getBH_inputs(*inputs)
+
+        return getBH_level2(
+            sources,
+            sensors,
+            field="J",
+            sumup=False,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out="auto",
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        items = []
+        if self.children_all:
+            nums = {
+                "sensor": len(self.sensors_all),
+                "source": len(self.sources_all),
+            }
+            for name, num in nums.items():
+                if num > 0:
+                    items.append(f"{num} {name}{'s'[: num ^ 1]}")
+        else:
+            items.append("no children")
+        return ", ".join(items)
+
+
+class Collection(BaseGeo, BaseCollection):
+    """Group multiple children (sources, sensors and collections) in a collection for
+    common manipulation.
+
+    Collections span a local reference frame. All objects in a collection are held to
+    that reference frame when an operation (e.g. move, rotate, setter, ...) is applied
+    to the collection.
+
+    Collections can be used as `sources` and `observers` input for magnetic field
+    computation. For magnetic field computation a collection that contains sources
+    functions like a single source. When the collection contains sensors
+    it functions like a list of all its sensors.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    children: sources, `Sensor` or `Collection` objects
+        An ordered list of all children in the collection.
+
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    override_parent: bool, default=False
+        If False thrown an error when an attempt is made to add an object that
+        has already a parent to a Collection. If True, allow adding the object
+        and override the objects parent attribute thus removing it from its
+        previous collection.
+
+    sensors: `Sensor` objects
+        An ordered list of all sensor objects in the collection.
+
+    sources: `Source` objects
+        An ordered list of all source objects (magnets, currents, misc) in the collection.
+
+    collections: `Collection` objects
+        An ordered list of all collection objects in the collection.
+
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    collection: `Collection` object
+
+    Examples
+    --------
+    Collections function as groups of multiple magpylib objects. In this example
+    we create a collection with two sources and move the whole collection:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src1 = magpy.magnet.Sphere(position=(2,0,0), diameter=1,polarization=(.1,.2,.3))
+    >>> src2 = magpy.current.Circle(position=(-2,0,0), diameter=1, current=1)
+    >>> col = magpy.Collection(src1, src2)
+    >>> col.move(((0,0,2)))
+    Collection(id=...)
+    >>> print(src1.position)
+    [2. 0. 2.]
+    >>> print(src2.position)
+    [-2.  0.  2.]
+    >>> print(col.position)
+    [0. 0. 2.]
+
+    We can still directly access individual objects by name and by index:
+
+    >>> src1.move((2,0,0))
+    Sphere(id=...)
+    >>> col[1].move((-2,0,0))
+    Circle(id=...)
+    >>> print(src1.position)
+    [4. 0. 2.]
+    >>> print(src2.position)
+    [-4.  0.  2.]
+    >>> print(col.position)
+    [0. 0. 2.]
+
+    The field can be computed at position (0,0,0) as if the collection was a single source:
+
+    >>> B = col.getB((0,0,0))
+    >>> print(B)
+    [ 2.32922681e-04 -9.31694991e-05 -3.44484717e-10]
+
+    We add a sensor at position (0,0,0) to the collection:
+
+    >>> sens = magpy.Sensor()
+    >>> col.add(sens)
+    Collection(id=...)
+    >>> print(col.children)
+    [Sphere(id=...), Circle(id=...), Sensor(id=...)]
+
+    and can compute the field of the sources in the collection seen by the sensor with
+    a single command:
+
+    >>> B = col.getB()
+    >>> with np.printoptions(precision=3):
+    ...    print(B)
+    [ 2.329e-04 -9.317e-05 -3.445e-10]
+    """
+
+    def __init__(
+        self,
+        *args,
+        position=(0, 0, 0),
+        orientation=None,
+        override_parent=False,
+        style=None,
+        **kwargs,
+    ):
+        BaseGeo.__init__(
+            self,
+            position=position,
+            orientation=orientation,
+            style=style,
+            **kwargs,
+        )
+        BaseCollection.__init__(self, *args, override_parent=override_parent)
diff --git a/src/magpylib/_src/obj_classes/class_Sensor.py b/src/magpylib/_src/obj_classes/class_Sensor.py
new file mode 100644
index 000000000..9f92172ea
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_Sensor.py
@@ -0,0 +1,525 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Sensor class code"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from magpylib._src.display.traces_core import make_Sensor
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.fields.field_wrap_BH import getBH_level2
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr
+from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
+from magpylib._src.style import SensorStyle
+from magpylib._src.utility import format_star_input
+
+
+class Sensor(BaseGeo, BaseDisplayRepr):
+    """Magnetic field sensor.
+
+    Can be used as `observers` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` local object coordinates
+    coincide with the global coordinate system.
+
+    A sensor is made up of pixel (sensing elements / positions) where the magnetic
+    field is evaluated.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    pixel: array_like, shape (3,) or (n1,n2,...,3), default=`(0,0,0)`
+        Sensor pixel (=sensing elements) positions in the local object coordinates
+        (rotate with object), in units of m.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    handedness: {"right", "left"}
+        Object local coordinate system handedness. If "left", the x-axis is flipped.
+
+    Returns
+    -------
+    observer: `Sensor` object
+
+    Examples
+    --------
+    `Sensor` objects are observers for magnetic field computation. In this example we compute the
+    B-field in units of T as seen by the sensor in the center of a circular current loop:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> sens = magpy.Sensor()
+    >>> loop = magpy.current.Circle(current=1, diameter=0.01)
+    >>> B = sens.getB(loop)
+    >>> with np.printoptions(precision=3):
+    ...     print(B*1000)
+    [0.    0.    0.126]
+
+    We rotate the sensor by 45 degrees and compute the field again:
+
+    >>> sens.rotate_from_rotvec((45,0,0))
+    Sensor(id=...)
+    >>> B = sens.getB(loop)
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [0.000e+00 8.886e-05 8.886e-05]
+
+    Finally we set some sensor pixels and compute the field again:
+
+    >>> sens.pixel=((0,0,0), (.001,0,0), (.002,0,0))
+    >>> B = sens.getB(loop)
+    >>> with np.printoptions(precision=3):
+    ...     print(B)
+    [[0.000e+00 8.886e-05 8.886e-05]
+     [0.000e+00 9.163e-05 9.163e-05]
+     [0.000e+00 1.014e-04 1.014e-04]]
+    """
+
+    _style_class = SensorStyle
+    _autosize = True
+    get_trace = make_Sensor
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        pixel=None,
+        handedness="right",
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.pixel = pixel
+        self.handedness = handedness
+
+        # init inheritance
+        BaseGeo.__init__(self, position, orientation, style=style, **kwargs)
+        BaseDisplayRepr.__init__(self)
+
+    # property getters and setters
+    @property
+    def pixel(self):
+        """Sensor pixel (=sensing elements) positions in the local object coordinates
+        (rotate with object), in units of m.
+        """
+        return self._pixel
+
+    @pixel.setter
+    def pixel(self, pix):
+        """Set sensor pixel positions in the local sensor coordinates.
+        Must be an array_like, float compatible with shape (..., 3)
+        """
+        self._pixel = check_format_input_vector(
+            pix,
+            dims=range(1, 20),
+            shape_m1=3,
+            sig_name="pixel",
+            sig_type="array_like (list, tuple, ndarray) with shape (n1, n2, ..., 3) or None",
+            allow_None=True,
+        )
+
+    @property
+    def handedness(self):
+        """Sensor handedness in the local object coordinates."""
+        return self._handedness
+
+    @handedness.setter
+    def handedness(self, val):
+        """Set Sensor handedness in the local object coordinates."""
+        if val not in {"right", "left"}:
+            msg = "Sensor `handedness` must be either `'right'` or `'left'`"
+            raise MagpylibBadUserInput(msg)
+        self._handedness = val
+
+    def getB(
+        self,
+        *sources,
+        sumup=False,
+        squeeze=True,
+        pixel_agg=None,
+        output="ndarray",
+        in_out="auto",
+    ):
+        """Compute the B-field in units of T as seen by the sensor.
+
+        Parameters
+        ----------
+        sources: source and collection objects or 1D list thereof
+            Sources that generate the magnetic field. Can be a single source (or collection)
+            or a 1D list of l source and/or collection objects.
+
+        sumup: bool, default=`False`
+            If `True`, the fields of all sources are summed up.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+            a single sensor or only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        B-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame
+            B-field of each source (index l) at each path position (index m) and each sensor pixel
+            position (indices n1,n2,...) in units of T. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        Sensors are observers for magnetic field computation. In this example we compute the
+        B-field in T as seen by the sensor in the center of a circular current loop:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor()
+        >>> loop = magpy.current.Circle(current=1, diameter=.01)
+        >>> B = sens.getB(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(B*1000)
+        [0.    0.    0.126]
+
+        Then we rotate the sensor by 45 degrees and compute the field again:
+
+        >>> sens.rotate_from_rotvec((45,0,0))
+        Sensor(id=...)
+        >>> B = sens.getB(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(B)
+        [0.000e+00 8.886e-05 8.886e-05]
+
+        Finally we set some sensor pixels and compute the field again:
+
+        >>> sens.pixel=((0,0,0), (.001,0,0), (.002,0,0))
+        >>> B = sens.getB(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(B)
+        [[0.000e+00 8.886e-05 8.886e-05]
+         [0.000e+00 9.163e-05 9.163e-05]
+         [0.000e+00 1.014e-04 1.014e-04]]
+        """
+        sources = format_star_input(sources)
+        return getBH_level2(
+            sources,
+            self,
+            field="B",
+            sumup=sumup,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getH(
+        self,
+        *sources,
+        sumup=False,
+        squeeze=True,
+        pixel_agg=None,
+        output="ndarray",
+        in_out="auto",
+    ):
+        """Compute the H-field in units of A/m as seen by the sensor.
+
+        Parameters
+        ----------
+        sources: source and collection objects or 1D list thereof
+            Sources that generate the magnetic field. Can be a single source (or collection)
+            or a 1D list of l source and/or collection objects.
+
+        sumup: bool, default=`False`
+            If `True`, the fields of all sources are summed up.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+            a single sensor or only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        H-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame
+            H-field of each source (index l) at each path position (index m) and each sensor pixel
+            position (indices n1,n2,...) in units of A/m. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        Sensors are observers for magnetic field computation. In this example we compute the
+        B-field in T as seen by the sensor in the center of a circular current loop:
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> sens = magpy.Sensor()
+        >>> loop = magpy.current.Circle(current=1, diameter=.01)
+        >>> H = sens.getH(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(H)
+        [  0.   0. 100.]
+
+        Then we rotate the sensor by 45 degrees and compute the field again:
+
+        >>> sens.rotate_from_rotvec((45,0,0))
+        Sensor(id=...)
+        >>> H = sens.getH(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(H)
+        [ 0.    70.711 70.711]
+
+        Finally we set some sensor pixels and compute the field again:
+
+        >>> sens.pixel=((0,0,0), (.001,0,0), (.002,0,0))
+        >>> H = sens.getH(loop)
+        >>> with np.printoptions(precision=3):
+        ...     print(H)
+        [[ 0.    70.711 70.711]
+         [ 0.    72.915 72.915]
+         [ 0.    80.704 80.704]]
+        """
+        sources = format_star_input(sources)
+        return getBH_level2(
+            sources,
+            self,
+            field="H",
+            sumup=sumup,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getM(
+        self,
+        *sources,
+        sumup=False,
+        squeeze=True,
+        pixel_agg=None,
+        output="ndarray",
+        in_out="auto",
+    ):
+        """Compute the M-field in units of A/m as seen by the sensor.
+
+        Parameters
+        ----------
+        sources: source and collection objects or 1D list thereof
+            Sources that generate the magnetic field. Can be a single source (or collection)
+            or a 1D list of l source and/or collection objects.
+
+        sumup: bool, default=`False`
+            If `True`, the fields of all sources are summed up.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+            a single sensor or only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        M-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame
+            M-field of each source (index l) at each path position (index m) and each sensor pixel
+            position (indices n1,n2,...) in units of A/m. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        Test if there is magnetization at the location of the sensor.
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... )
+        >>> sens = magpy.Sensor()
+        >>> M = sens.getM(cube)
+        >>> with np.printoptions(precision=0):
+        ...    print(M)
+        [795775.      0.      0.]
+        """
+        sources = format_star_input(sources)
+        return getBH_level2(
+            sources,
+            self,
+            field="M",
+            sumup=sumup,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    def getJ(
+        self,
+        *sources,
+        sumup=False,
+        squeeze=True,
+        pixel_agg=None,
+        output="ndarray",
+        in_out="auto",
+    ):
+        """Compute the J-field in units of T as seen by the sensor.
+
+        Parameters
+        ----------
+        sources: source and collection objects or 1D list thereof
+            Sources that generate the magnetic field. Can be a single source (or collection)
+            or a 1D list of l source and/or collection objects.
+
+        sumup: bool, default=`False`
+            If `True`, the fields of all sources are summed up.
+
+        squeeze: bool, default=`True`
+            If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only
+            a single sensor or only a single source) are eliminated.
+
+        pixel_agg: str, default=`None`
+            Reference to a compatible numpy aggregator function like `'min'` or `'mean'`,
+            which is applied to observer output values, e.g. mean of all sensor pixel outputs.
+            With this option, observers input with different (pixel) shapes is allowed.
+
+        output: str, default='ndarray'
+            Output type, which must be one of `('ndarray', 'dataframe')`. By default a
+            `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame`
+            object is returned (the Pandas library must be installed).
+
+        in_out: {'auto', 'inside', 'outside'}
+            This parameter only applies for magnet bodies. It specifies the location of the
+            observers relative to the magnet body, affecting the calculation of the magnetic field.
+            The options are:
+            - 'auto': The location (inside or outside the cuboid) is determined automatically for
+            each observer.
+            - 'inside': All observers are considered to be inside the cuboid; use this for
+              performance optimization if applicable.
+            - 'outside': All observers are considered to be outside the cuboid; use this for
+              performance optimization if applicable.
+            Choosing 'auto' is fail-safe but may be computationally intensive if the mix of observer
+            locations is unknown.
+
+        Returns
+        -------
+        J-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame
+            J-field of each source (index l) at each path position (index m) and each sensor pixel
+            position (indices n1,n2,...) in units of T. Paths of objects that are shorter than
+            index m are considered as static beyond their end.
+
+        Examples
+        --------
+        Test if there is polarization at the location of the sensor.
+
+        >>> import numpy as np
+        >>> import magpylib as magpy
+        >>> cube = magpy.magnet.Cuboid(
+        ...     dimension=(10,1,1),
+        ...     polarization=(1,0,0)
+        ... )
+        >>> sens = magpy.Sensor()
+        >>> J = sens.getJ(cube)
+        >>> with np.printoptions(precision=0):
+        ...    print(J)
+        [1. 0. 0.]
+        """
+        sources = format_star_input(sources)
+        return getBH_level2(
+            sources,
+            self,
+            field="J",
+            sumup=sumup,
+            squeeze=squeeze,
+            pixel_agg=pixel_agg,
+            output=output,
+            in_out=in_out,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        pix = self.pixel
+        desc = ""
+        if pix is not None:
+            px_shape = pix.shape[:-1]
+            nop = int(np.prod(px_shape))
+            if pix.ndim > 2:
+                desc += f"{'x'.join(str(p) for p in px_shape)}="
+            desc += f"{nop} pixel{'s'[: nop ^ 1]}"
+        return desc
diff --git a/src/magpylib/_src/obj_classes/class_current_Circle.py b/src/magpylib/_src/obj_classes/class_current_Circle.py
new file mode 100644
index 000000000..b9291dc16
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_current_Circle.py
@@ -0,0 +1,138 @@
+"""CircularCircle current class code"""
+
+from __future__ import annotations
+
+import warnings
+from typing import ClassVar
+
+from magpylib._src.display.traces_core import make_Circle
+from magpylib._src.exceptions import MagpylibDeprecationWarning
+from magpylib._src.fields.field_BH_circle import BHJM_circle
+from magpylib._src.input_checks import check_format_input_scalar
+from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent
+from magpylib._src.utility import unit_prefix
+
+
+class Circle(BaseCurrent):
+    """Circular current loop.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the current loop lies
+    in the x-y plane of the global coordinate system, with its center in
+    the origin.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    diameter: float, default=`None`
+        Diameter of the loop in units of m.
+
+    current: float, default=`None`
+        Electrical current in units of A.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    current source: `Circle` object
+
+    Examples
+    --------
+    `Circle` objects are magnetic field sources. In this example we compute the H-field in A/m
+    of such a current loop with 100 A current and a diameter of 2 meters at the observer position
+    (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.current.Circle(current=100, diameter=2)
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [7.501e-03 7.501e-03 5.000e+01]
+    """
+
+    _field_func = staticmethod(BHJM_circle)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {"current": 1, "diameter": 1}
+    get_trace = make_Circle
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        diameter=None,
+        current=None,
+        *,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.diameter = diameter
+
+        # init inheritance
+        super().__init__(position, orientation, current, style, **kwargs)
+
+    # property getters and setters
+    @property
+    def diameter(self):
+        """Diameter of the loop in units of m."""
+        return self._diameter
+
+    @diameter.setter
+    def diameter(self, dia):
+        """Set Circle loop diameter, float, meter."""
+        self._diameter = check_format_input_scalar(
+            dia,
+            sig_name="diameter",
+            sig_type="`None` or a positive number (int, float)",
+            allow_None=True,
+            forbid_negative=True,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.diameter is None:
+            return "no dimension"
+        return f"{unit_prefix(self.current)}A" if self.current else "no current"
+
+
+class Loop(Circle):
+    """Loop is deprecated, see Circle"""
+
+    # pylint: disable=method-hidden
+    @staticmethod
+    def _field_func(*args, **kwargs):
+        """Catch Deprecation warning in getBH_dict"""
+        _deprecation_warn()
+        return BHJM_circle(*args, **kwargs)
+
+    def __init__(self, *args, **kwargs):
+        _deprecation_warn()
+        super().__init__(*args, **kwargs)
+
+
+def _deprecation_warn():
+    warnings.warn(
+        (
+            "Loop is deprecated  and will be removed in a future version, "
+            "use Circle instead."
+        ),
+        MagpylibDeprecationWarning,
+        stacklevel=2,
+    )
diff --git a/src/magpylib/_src/obj_classes/class_current_Polyline.py b/src/magpylib/_src/obj_classes/class_current_Polyline.py
new file mode 100644
index 000000000..8794265cd
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_current_Polyline.py
@@ -0,0 +1,148 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Polyline current class code"""
+
+from __future__ import annotations
+
+import warnings
+from typing import ClassVar
+
+from magpylib._src.display.traces_core import make_Polyline
+from magpylib._src.exceptions import MagpylibDeprecationWarning
+from magpylib._src.fields.field_BH_polyline import current_vertices_field
+from magpylib._src.input_checks import check_format_input_vertices
+from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent
+from magpylib._src.utility import unit_prefix
+
+
+class Polyline(BaseCurrent):
+    """Line current flowing in straight paths from vertex to vertex.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    The vertex positions are defined in the local object coordinates (rotate with object).
+    When `position=(0,0,0)` and `orientation=None` global and local coordinates coincide.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    vertices: array_like, shape (n,3), default=`None`
+        The current flows along the vertices which are given in units of m in the
+        local object coordinates (move/rotate with object). At least two vertices
+        must be given.
+
+    current: float, default=`None`
+        Electrical current in units of A.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    current source: `Polyline` object
+
+    Examples
+    --------
+    `Polyline` objects are magnetic field sources. In this example we compute the H-field in A/m
+    of a square-shaped line-current with 1 A current at the observer position (1,1,1) given in
+    units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.current.Polyline(
+    ...     current=1,
+    ...     vertices=((.01,0,0), (0,.01,0), (-.01,0,0), (0,-.01,0), (.01,0,0)),
+    ... )
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [3.161 3.161 0.767]
+
+    """
+
+    # pylint: disable=dangerous-default-value
+    _field_func = staticmethod(current_vertices_field)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "current": 1,
+        "vertices": 3,
+        "segment_start": 2,
+        "segment_end": 2,
+    }
+    get_trace = make_Polyline
+
+    def __init__(
+        self,
+        current=None,
+        vertices=None,
+        position=(0, 0, 0),
+        orientation=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.vertices = vertices
+
+        # init inheritance
+        super().__init__(position, orientation, current, style, **kwargs)
+
+    # property getters and setters
+    @property
+    def vertices(self):
+        """
+        The current flows along the vertices which are given in units of m in the
+        local object coordinates (move/rotate with object). At least two vertices
+        must be given.
+        """
+        return self._vertices
+
+    @vertices.setter
+    def vertices(self, vert):
+        """Set Polyline vertices, array_like, meter."""
+        self._vertices = check_format_input_vertices(vert)
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.vertices is None:
+            return "no vertices"
+        return f"{unit_prefix(self.current)}A" if self.current else "no current"
+
+
+class Line(Polyline):
+    """Line is deprecated, see Polyline"""
+
+    # pylint: disable=method-hidden
+    @staticmethod
+    def _field_func(*args, **kwargs):
+        """Catch Deprecation warning in getBH_dict"""
+        _deprecation_warn()
+        return current_vertices_field(*args, **kwargs)
+
+    def __init__(self, *args, **kwargs):
+        _deprecation_warn()
+        super().__init__(*args, **kwargs)
+
+
+def _deprecation_warn():
+    warnings.warn(
+        (
+            "Line is deprecated and will be removed in a future version, "
+            "use Polyline instead."
+        ),
+        MagpylibDeprecationWarning,
+        stacklevel=2,
+    )
diff --git a/src/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/src/magpylib/_src/obj_classes/class_magnet_Cuboid.py
new file mode 100644
index 000000000..e09580faa
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_Cuboid.py
@@ -0,0 +1,126 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Cuboid class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from magpylib._src.display.traces_core import make_Cuboid
+from magpylib._src.fields.field_BH_cuboid import BHJM_magnet_cuboid
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.utility import unit_prefix
+
+
+class Cuboid(BaseMagnet):
+    """Cuboid magnet with homogeneous magnetization.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the Cuboid sides are parallel
+    to the global coordinate basis vectors and the geometric center of the Cuboid
+    is located in the origin.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m.
+        For m>1, the `position` and `orientation` attributes together
+        represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    dimension: array_like, shape (3,), default=`None`
+        Length of the cuboid sides [a,b,c] in meters.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    magnet source: `Cuboid` object
+
+    Examples
+    --------
+    `Cuboid` magnets are magnetic field sources. Below we compute the H-field in A/m of a
+    cubical magnet with magnetic polarization of (0.5,0.6,0.7) in units of T and
+    0.01 meter sides at the observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.magnet.Cuboid(polarization=(.5,.6,.7), dimension=(.01,.01,.01))
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=0):
+    ...     print(H)
+    [16149. 14907. 13665.]
+    """
+
+    _field_func = staticmethod(BHJM_magnet_cuboid)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 2,
+        "dimension": 2,
+    }
+    get_trace = make_Cuboid
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        dimension=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.dimension = dimension
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def dimension(self):
+        """Length of the cuboid sides [a,b,c] in arbitrary length units, e.g. in meter."""
+        return self._dimension
+
+    @dimension.setter
+    def dimension(self, dim):
+        """Set Cuboid dimension (a,b,c), shape (3,)"""
+        self._dimension = check_format_input_vector(
+            dim,
+            dims=(1,),
+            shape_m1=3,
+            sig_name="Cuboid.dimension",
+            sig_type="array_like (list, tuple, ndarray) of shape (3,) with positive values",
+            allow_None=True,
+            forbid_negative0=True,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.dimension is None:
+            return "no dimension"
+        d = [unit_prefix(d) for d in self.dimension]
+        return f"{d[0]}m|{d[1]}m|{d[2]}m"
diff --git a/src/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/src/magpylib/_src/obj_classes/class_magnet_Cylinder.py
new file mode 100644
index 000000000..7c25e2e55
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_Cylinder.py
@@ -0,0 +1,125 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Cylinder class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from magpylib._src.display.traces_core import make_Cylinder
+from magpylib._src.fields.field_BH_cylinder import BHJM_magnet_cylinder
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.utility import unit_prefix
+
+
+class Cylinder(BaseMagnet):
+    """Cylinder magnet with homogeneous magnetization.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the geometric center of the
+    cylinder lies in the origin of the global coordinate system and
+    the cylinder axis coincides with the global z-axis.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    dimension: array_like, shape (2,), default=`None`
+        Dimension (d,h) denote diameter and height of the cylinder in units of m.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    magnet source: `Cylinder`
+
+    Examples
+    --------
+    `Cylinder` magnets are magnetic field sources. Below we compute the H-field in A/m of a
+    cylinder magnet with polarization (.1,.2,.3) in units of T and 0.01 meter diameter and
+    height at the observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.magnet.Cylinder(polarization=(.1,.2,.3), dimension=(.01,.01))
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [4849.913 3883.178 2739.732]
+    """
+
+    _field_func = staticmethod(BHJM_magnet_cylinder)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 2,
+        "dimension": 2,
+    }
+    get_trace = make_Cylinder
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        dimension=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.dimension = dimension
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def dimension(self):
+        """Dimension (d,h) denote diameter and height of the cylinder in units of m."""
+        return self._dimension
+
+    @dimension.setter
+    def dimension(self, dim):
+        """Set Cylinder dimension (d,h) in units of m."""
+        self._dimension = check_format_input_vector(
+            dim,
+            dims=(1,),
+            shape_m1=2,
+            sig_name="Cylinder.dimension",
+            sig_type="array_like (list, tuple, ndarray) with shape (2,) with positive values",
+            allow_None=True,
+            forbid_negative0=True,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.dimension is None:
+            return "no dimension"
+        d = [unit_prefix(d) for d in self.dimension]
+        return f"D={d[0]}m, H={d[1]}m"
diff --git a/src/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/src/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py
new file mode 100644
index 000000000..44fc15107
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py
@@ -0,0 +1,168 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Cylinder class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from magpylib._src.display.traces_core import make_CylinderSegment
+from magpylib._src.fields.field_BH_cylinder_segment import (
+    BHJM_cylinder_segment_internal,
+)
+from magpylib._src.input_checks import check_format_input_cylinder_segment
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.utility import unit_prefix
+
+
+class CylinderSegment(BaseMagnet):
+    """Cylinder segment (ring-section) magnet with homogeneous magnetization.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the geometric center of the
+    cylinder lies in the origin of the global coordinate system and
+    the cylinder axis coincides with the global z-axis. Section angle 0
+    corresponds to an x-z plane section of the cylinder.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    dimension: array_like, shape (5,), default=`None`
+        Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2)
+        where r1<r2 denote inner and outer radii in units of m, phi1<phi2 denote
+        the cylinder section angles in units of deg and h is the cylinder height
+        in units of m.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Attributes
+    ----------
+    barycenter: array_like, shape (3,)
+        Read only property that returns the geometric barycenter (=center of mass)
+        of the object.
+
+    Returns
+    -------
+    magnet source: `CylinderSegment` object
+
+    Examples
+    --------
+    `CylinderSegment` magnets are magnetic field sources. In this example we compute the
+    H-field in A/m of such a cylinder segment magnet with polarization (.1,.2,.3)
+    in units of T, inner radius 0.01 meter, outer radius 0.02 meter, height 0.01 meter, and
+    section angles 0 and 45 deg at the observer position (0.02,0.02,0.02) in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.magnet.CylinderSegment(polarization=(.1,.2,.3), dimension=(.01,.02,.01,0,45))
+    >>> H = src.getH((.02,.02,.02))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [ 807.847 1934.228 2741.168]
+    """
+
+    _field_func = staticmethod(BHJM_cylinder_segment_internal)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 2,
+        "dimension": 2,
+    }
+    get_trace = make_CylinderSegment
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        dimension=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.dimension = dimension
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def dimension(self):
+        """
+        Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2)
+        where r1<r2 denote inner and outer radii in units of m, phi1<phi2 denote
+        the cylinder section angles in units of deg and h is the cylinder height
+        in units of m.
+        """
+        return self._dimension
+
+    @dimension.setter
+    def dimension(self, dim):
+        """Set Cylinder dimension (r1,r2,h,phi1,phi2), shape (5,), (meter, deg)."""
+        self._dimension = check_format_input_cylinder_segment(dim)
+
+    @property
+    def _barycenter(self):
+        """Object barycenter."""
+        return self._get_barycenter(self._position, self._orientation, self.dimension)
+
+    @property
+    def barycenter(self):
+        """Object barycenter."""
+        return np.squeeze(self._barycenter)
+
+    @staticmethod
+    def _get_barycenter(position, orientation, dimension):
+        """Returns the barycenter of a cylinder segment.
+        Input checks should make sure:
+            -360 < phi1 < phi2 < 360 and 0 < r1 < r2
+        """
+        if dimension is None:
+            centroid = np.array([0.0, 0.0, 0.0])
+        else:
+            r1, r2, _, phi1, phi2 = dimension
+            alpha = np.deg2rad((phi2 - phi1) / 2)
+            phi = np.deg2rad((phi1 + phi2) / 2)
+            # get centroid x for unrotated annular sector
+            centroid_x = (
+                2 / 3 * np.sin(alpha) / alpha * (r2**3 - r1**3) / (r2**2 - r1**2)
+            )
+            # get centroid for rotated annular sector
+            x, y, z = centroid_x * np.cos(phi), centroid_x * np.sin(phi), 0
+            centroid = np.array([x, y, z])
+        return orientation.apply(centroid) + position
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.dimension is None:
+            return "no dimension"
+        d = [unit_prefix(d) for d in self.dimension]
+        return f"r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°"
diff --git a/src/magpylib/_src/obj_classes/class_magnet_Sphere.py b/src/magpylib/_src/obj_classes/class_magnet_Sphere.py
new file mode 100644
index 000000000..8a5f08ee2
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_Sphere.py
@@ -0,0 +1,122 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Sphere class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from magpylib._src.display.traces_core import make_Sphere
+from magpylib._src.fields.field_BH_sphere import BHJM_magnet_sphere
+from magpylib._src.input_checks import check_format_input_scalar
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.utility import unit_prefix
+
+
+class Sphere(BaseMagnet):
+    """Spherical magnet with homogeneous magnetization.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the sphere center is located
+    in the origin of the global coordinate system.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    diameter: float, default=`None`
+        Diameter of the sphere in units of m.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+
+    Returns
+    -------
+    magnet source: `Sphere` object
+
+    Examples
+    --------
+    `Sphere` objects are magnetic field sources. In this example we compute the H-field in A/m
+    of a spherical magnet with polarization (0.1,0.2,0.3) in units of T and diameter
+    of 0.01 meter at the observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.magnet.Sphere(polarization=(.1,.2,.3), diameter=.01)
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [3190.561 2552.449 1914.336]
+    """
+
+    _field_func = staticmethod(BHJM_magnet_sphere)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 2,
+        "diameter": 1,
+    }
+    get_trace = make_Sphere
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        diameter=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.diameter = diameter
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def diameter(self):
+        """Diameter of the sphere in units of m."""
+        return self._diameter
+
+    @diameter.setter
+    def diameter(self, dia):
+        """Set Sphere diameter, float, meter."""
+        self._diameter = check_format_input_scalar(
+            dia,
+            sig_name="diameter",
+            sig_type="`None` or a positive number (int, float)",
+            allow_None=True,
+            forbid_negative=True,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.diameter is None:
+            return "no dimension"
+        return f"D={unit_prefix(self.diameter)}m"
diff --git a/src/magpylib/_src/obj_classes/class_magnet_Tetrahedron.py b/src/magpylib/_src/obj_classes/class_magnet_Tetrahedron.py
new file mode 100644
index 000000000..c463e833e
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_Tetrahedron.py
@@ -0,0 +1,154 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Tetrahedron class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from magpylib._src.display.traces_core import make_Tetrahedron
+from magpylib._src.fields.field_BH_tetrahedron import BHJM_magnet_tetrahedron
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+
+
+class Tetrahedron(BaseMagnet):
+    """Tetrahedron magnet with homogeneous magnetization.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the Tetrahedron vertices coordinates
+    are the same as in the global coordinate system. The geometric center of the Tetrahedron
+    is determined by its vertices and. It is not necessarily located in the origin an can
+    be computed with the barycenter property.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3)
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+        When setting vertices, the initial position is set to the barycenter.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    vertices: ndarray, shape (4,3)
+        Vertices [(x1,y1,z1), (x2,y2,z2), (x3,y3,z3), (x4,y4,z4)], in the relative
+        coordinate system of the tetrahedron.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Attributes
+    ----------
+    barycenter: array_like, shape (3,)
+        Read only property that returns the geometric barycenter (=center of mass)
+        of the object.
+
+    Returns
+    -------
+    magnet source: `Tetrahedron` object
+
+    Examples
+    --------
+    `Tetrahedron` magnets are magnetic field sources. Below we compute the H-field in A/m of a
+    tetrahedron magnet with polarization (0.1,0.2,0.3) in units of T dimensions defined
+    through the vertices (0,0,0), (.01,0,0), (0,.01,0) and (0,0,.01) in units of m at the
+    observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> verts = [(0,0,0), (.01,0,0), (0,.01,0), (0,0,.01)]
+    >>> src = magpy.magnet.Tetrahedron(polarization=(.1,.2,.3), vertices=verts)
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [2070.898 1656.718 1242.539]
+    """
+
+    _field_func = staticmethod(BHJM_magnet_tetrahedron)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 1,
+        "vertices": 3,
+    }
+    get_trace = make_Tetrahedron
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        vertices=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.vertices = vertices
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def vertices(self):
+        """Length of the Tetrahedron sides [a,b,c] in units of m."""
+        return self._vertices
+
+    @vertices.setter
+    def vertices(self, dim):
+        """Set Tetrahedron vertices (a,b,c), shape (3,), (meter)."""
+        self._vertices = check_format_input_vector(
+            dim,
+            dims=(2,),
+            shape_m1=3,
+            length=4,
+            sig_name="Tetrahedron.vertices",
+            sig_type="array_like (list, tuple, ndarray) of shape (4,3)",
+            allow_None=True,
+        )
+
+    @property
+    def _barycenter(self):
+        """Object barycenter."""
+        return self._get_barycenter(self._position, self._orientation, self.vertices)
+
+    @property
+    def barycenter(self):
+        """Object barycenter."""
+        return np.squeeze(self._barycenter)
+
+    @staticmethod
+    def _get_barycenter(position, orientation, vertices):
+        """Returns the barycenter of a tetrahedron."""
+        centroid = (
+            np.array([0.0, 0.0, 0.0]) if vertices is None else np.mean(vertices, axis=0)
+        )
+        return orientation.apply(centroid) + position
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.vertices is None:
+            return "no vertices"
+        return ""
diff --git a/src/magpylib/_src/obj_classes/class_magnet_TriangularMesh.py b/src/magpylib/_src/obj_classes/class_magnet_TriangularMesh.py
new file mode 100644
index 000000000..263f1c1ba
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_magnet_TriangularMesh.py
@@ -0,0 +1,944 @@
+"""Magnet TriangularMesh class code"""
+
+from __future__ import annotations
+
+import warnings
+from typing import ClassVar
+
+import numpy as np
+from scipy.spatial import ConvexHull  # pylint: disable=no-name-in-module
+
+from magpylib._src.display.traces_core import make_TriangularMesh
+from magpylib._src.exceptions import MagpylibMissingInput
+from magpylib._src.fields.field_BH_triangularmesh import (
+    BHJM_magnet_trimesh,
+    calculate_centroid,
+    fix_trimesh_orientation,
+    get_disconnected_faces_subsets,
+    get_intersecting_triangles,
+    get_open_edges,
+)
+from magpylib._src.input_checks import (
+    check_format_input_vector,
+    check_format_input_vector2,
+)
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.obj_classes.class_Collection import Collection
+from magpylib._src.obj_classes.class_misc_Triangle import Triangle
+from magpylib._src.style import TriangularMeshStyle
+
+# pylint: disable=too-many-instance-attributes
+# pylint: disable=too-many-public-methods
+# pylint: disable=too-many-positional-arguments
+
+
+class TriangularMesh(BaseMagnet):
+    """Magnet with homogeneous magnetization defined by triangular surface mesh.
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the TriangularMesh vertices
+    are the same as in the global coordinate system.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    vertices: ndarray, shape (n,3)
+        A set of points in units of m in the local object coordinates from which the
+        triangular faces of the mesh are constructed by the additional `faces`input.
+
+    faces: ndarray, shape (n,3)
+        Indices of vertices. Each triplet represents one triangle of the mesh.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).
+
+    reorient_faces: bool or string, default=`True`
+        In a properly oriented mesh, all faces must be oriented outwards.
+        If `True`, check and fix the orientation of each triangle.
+        Options are `'skip'`(=`False`), `'warn'`(=`True`), `'raise'`, `'ignore'`.
+
+    check_open: bool or string, default=`True`
+        Only a closed mesh guarantees correct B-field computation.
+        If `True`, check if mesh is open.
+        Options are `'skip'`(=`False`), `'warn'`(=`True`), `'raise'`, `'ignore'`.
+
+    check_disconnected: bool or string, default=`True`
+        Individual magnets should be connected bodies to avoid confusion.
+        If `True`, check if mesh is disconnected.
+        Options are `'skip'`(=`False`), `'warn'`(=`True`), `'raise'`, `'ignore'`.
+
+    check_selfintersecting: bool or string, default=`True`
+        a proper body cannot have a self-intersecting mesh.
+        If `True`, check if mesh is self-intersecting.
+        Options are `'skip'`(=`False`), `'warn'`(=`True`), `'raise'`, `'ignore'`.
+
+    check_selfintersecting: bool, optional
+        If `True`, the provided set of facets is validated by checking if the body is not
+        self-intersecting. Can be deactivated for performance reasons by setting it to `False`.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Notes
+    -----
+    Faces are automatically reoriented since `scipy.spatial.ConvexHull` objects do not
+    guarantee that the faces are all pointing outwards. A mesh validation is also performed.
+
+    Returns
+    -------
+    magnet source: `TriangularMesh` object
+
+    Examples
+    --------
+    We compute the B-field in units of T of a triangular mesh (4 vertices, 4 faces)
+    with polarization (0.1,0.2,0.3) in units of T at the observer position
+    (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> vv = ((0,0,0), (.01,0,0), (0,.01,0), (0,0,.01))
+    >>> tt = ((0,1,2), (0,1,3), (0,2,3), (1,2,3))
+    >>> trim = magpy.magnet.TriangularMesh(polarization=(.1,.2,.3), vertices=vv, faces=tt)
+    >>> with np.printoptions(precision=3):
+    ...     print(trim.getB((.01,.01,.01))*1000)
+    [2.602 2.082 1.561]
+    """
+
+    _field_func = staticmethod(BHJM_magnet_trimesh)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {"polarization": 2, "mesh": 3}
+    get_trace = make_TriangularMesh
+    _style_class = TriangularMeshStyle
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        vertices=None,
+        faces=None,
+        polarization=None,
+        magnetization=None,
+        check_open="warn",
+        check_disconnected="warn",
+        check_selfintersecting="warn",
+        reorient_faces="warn",
+        style=None,
+        **kwargs,
+    ):
+        self._vertices, self._faces = self._input_check(vertices, faces)
+        self._status_disconnected = None
+        self._status_open = None
+        self._status_reoriented = False
+        self._status_selfintersecting = None
+        self._status_disconnected_data = None
+        self._status_open_data = None
+        self._status_selfintersecting_data = None
+
+        self.check_open(mode=check_open)
+        self.check_disconnected(mode=check_disconnected)
+        self.reorient_faces(mode=reorient_faces)
+        self.check_selfintersecting(mode=check_selfintersecting)
+
+        # inherit
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def vertices(self):
+        """Mesh vertices"""
+        return self._vertices
+
+    @property
+    def faces(self):
+        """Mesh faces"""
+        return self._faces
+
+    @property
+    def mesh(self):
+        """Mesh"""
+        return self._vertices[self._faces]
+
+    @property
+    def status_open(self):
+        """Return open status"""
+        return self._status_open
+
+    @property
+    def status_disconnected(self):
+        """Return disconnected status"""
+        return self._status_disconnected
+
+    @property
+    def status_reoriented(self):
+        """Return reoriented status"""
+        return self._status_reoriented
+
+    @staticmethod
+    def _validate_mode_arg(arg, arg_name="mode"):
+        """Validate mode argument"""
+        accepted_arg_vals = (True, False, "warn", "raise", "ignore", "skip")
+        if arg not in accepted_arg_vals:
+            msg = (
+                f"The `{arg_name}` argument must be one of {accepted_arg_vals}, "
+                f"instead received {arg!r}."
+                f"\nNote that `True` translates to `'warn'` and `False` to `'skip'`"
+            )
+            raise ValueError(msg)
+        return "warn" if arg is True else "skip" if arg is False else arg
+
+    def check_open(self, mode="warn"):
+        """
+        Check whether the mesh is closed.
+
+        This function checks if the mesh is closed. If the mesh is not closed,
+        it issues a warning or raises a ValueError, depending on the 'mode' parameter.
+        If 'mode' is set to 'ignore', it does not issue a warning or raise an error.
+
+        Parameters
+        ----------
+        mode : str, optional
+            Controls how to handle if the mesh is not closed.
+            Accepted values are "warn", "raise", or "ignore".
+            If "warn", a warning is issued. If "raise", a ValueError is raised.
+            If "ignore", no action is taken. By default "warn".
+
+        Returns
+        -------
+        bool
+            True if the mesh is open, False otherwise.
+
+        Raises
+        ------
+        ValueError
+            If 'mode' is not one of the accepted values or if 'mode' is "raise" and the mesh
+            is open.
+
+        Warns
+        -----
+        UserWarning
+            If the mesh is open and 'mode' is "warn".
+        """
+        mode = self._validate_mode_arg(mode, arg_name="check_open mode")
+        if mode != "skip" and self._status_open is None:
+            self._status_open = len(self.get_open_edges()) > 0
+            if self._status_open:
+                msg = (
+                    f"Open mesh detected in {self!r}. Intrinsic inside-outside checks may "
+                    "give bad results and subsequently getB() and reorient_faces() may give bad "
+                    "results as well. "
+                    "This check can be disabled at initialization with check_open='skip'. "
+                    "Open edges can be displayed in show() with style_mesh_open_show=True."
+                    "Open edges are stored in the status_open_data property."
+                )
+                if mode == "warn":
+                    warnings.warn(msg, stacklevel=2)
+                elif mode == "raise":
+                    raise ValueError(msg)
+        return self._status_open
+
+    def check_disconnected(self, mode="warn"):
+        """Check whether the mesh is connected.
+
+        This function checks if the mesh is connected. If the mesh is not connected,
+        it issues a warning or raises a ValueError, depending on the 'mode' parameter.
+        If 'mode' is set to 'ignore', it does not issue a warning or raise an error.
+
+        Parameters
+        ----------
+        mode : str, optional
+            Controls how to handle if the mesh is not connected.
+            Accepted values are "warn", "raise", or "ignore".
+            If "warn", a warning is issued. If "raise", a ValueError is raised.
+            If "ignore", no action is taken. By default "warn".
+
+        Returns
+        -------
+        bool
+            True if the mesh is disconnected, False otherwise.
+
+        Raises
+        ------
+        ValueError
+            If 'mode' is not one of the accepted values or if 'mode' is "raise" and the mesh
+            is disconnected.
+
+        Warns
+        -----
+        UserWarning
+            If the mesh is disconnected and 'mode' is "warn".
+        """
+        mode = self._validate_mode_arg(mode, arg_name="check_disconnected mode")
+        if mode != "skip" and self._status_disconnected is None:
+            self._status_disconnected = len(self.get_faces_subsets()) > 1
+            if self._status_disconnected:
+                msg = (
+                    f"Disconnected mesh detected in {self!r}. Magnet consists of multiple "
+                    "individual parts. "
+                    "This check can be disabled at initialization with check_disconnected='skip'. "
+                    "Parts can be displayed in show() with style_mesh_disconnected_show=True. "
+                    "Parts are stored in the status_disconnected_data property."
+                )
+                if mode == "warn":
+                    warnings.warn(msg, stacklevel=2)
+                elif mode == "raise":
+                    raise ValueError(msg)
+        return self._status_disconnected
+
+    def check_selfintersecting(self, mode="warn"):
+        """Check whether the mesh is self-intersecting.
+
+        This function checks if the mesh is self-intersecting. If the mesh is self-intersecting,
+        it issues a warning or raises a ValueError, depending on the 'mode' parameter.
+        If 'mode' is set to 'ignore', it does not issue a warning or raise an error.
+
+        Parameters
+        ----------
+        mode : str, optional
+            Controls how to handle if the mesh is self-intersecting.
+            Accepted values are "warn", "raise", or "ignore".
+            If "warn", a warning is issued. If "raise", a ValueError is raised.
+            If "ignore", no action is taken. By default "warn".
+
+        Returns
+        -------
+        bool
+            True if the mesh is self-intersecting, False otherwise.
+
+        Raises
+        ------
+        ValueError
+            If 'mode' is not one of the accepted values or if 'mode' is "raise" and the mesh
+            is self-intersecting.
+
+        Warns
+        -----
+        UserWarning
+            If the mesh is self-intersecting and 'mode' is "warn".
+        """
+        mode = self._validate_mode_arg(mode, arg_name="check_selfintersecting mode")
+        if mode != "skip" and self._status_selfintersecting is None:
+            self._status_selfintersecting = len(self.get_selfintersecting_faces()) > 1
+            if self._status_selfintersecting:
+                msg = (
+                    f"Self-intersecting mesh detected in {self!r}. "
+                    "This check can be disabled at initialization with "
+                    "check_selfintersecting='skip'. "
+                    "Intersecting faces can be display in show() with "
+                    "style_mesh_selfintersecting_show=True. "
+                    "Parts are stored in the status_selfintersecting_data property."
+                )
+                if mode == "warn":
+                    warnings.warn(msg, stacklevel=2)
+                elif mode == "raise":
+                    raise ValueError(msg)
+        return self._status_selfintersecting
+
+    def reorient_faces(self, mode="warn"):
+        """Correctly reorients the mesh's faces.
+
+        In a properly oriented mesh, all faces must be oriented outwards. This function
+        fixes the orientation of each face. It issues a warning or raises a ValueError,
+        depending on the 'mode' parameter. If 'mode' is set to 'ignore', it does not issue
+        a warning or raise an error. Note that this parameter is passed on the check_closed()
+        function as the mesh is only orientable if it is closed.
+
+        Parameters
+        ----------
+        mode : str, optional
+            Controls how to handle if the mesh is open and not orientable.
+            Accepted values are "warn", "raise", or "ignore".
+            If "warn", a warning is issued. If "raise", a ValueError is raised.
+            If "ignore", no action is taken. By default "warn".
+
+        Returns
+        -------
+        bool
+            True if the mesh is connected, False otherwise.
+
+        Raises
+        ------
+        ValueError
+            If 'mode' is not one of the accepted values or if 'mode' is "raise" and the mesh
+            is open and not orientable.
+
+        Warns
+        -----
+        UserWarning
+            If the mesh is not connected and 'mode' is "warn".
+        """
+        mode = self._validate_mode_arg(mode, arg_name="reorient_faces mode")
+        if mode != "skip":
+            if self._status_open is None:
+                if mode in ["warn", "raise"]:
+                    warnings.warn(
+                        f"Unchecked mesh status in {self!r} detected. Now applying check_open()",
+                        stacklevel=2,
+                    )
+                self.check_open(mode=mode)
+
+            if self._status_open:
+                msg = f"Open mesh in {self!r} detected. reorient_faces() can give bad results."
+                if mode == "warn":
+                    warnings.warn(msg, stacklevel=2)
+                elif mode == "raise":
+                    raise ValueError(msg)
+
+            self._faces = fix_trimesh_orientation(self._vertices, self._faces)
+            self._status_reoriented = True
+
+    def get_faces_subsets(self):
+        """
+        Obtain and return subsets of the mesh. If the mesh has n parts, returns and list of
+        length n of faces (m,3) vertices indices triplets corresponding to each part.
+
+        Returns
+        -------
+        status_disconnected_data : list of numpy.ndarray
+            Subsets of faces data.
+        """
+        if self._status_disconnected_data is None:
+            self._status_disconnected_data = get_disconnected_faces_subsets(self._faces)
+        return self._status_disconnected_data
+
+    def get_open_edges(self):
+        """
+        Obtain and return the potential open edges. If the mesh has n open edges, returns an
+        corresponding (n,2) array of vertices indices doubles.
+
+        Returns
+        -------
+        status_open_data : numpy.ndarray
+            Open edges data.
+        """
+        if self._status_open_data is None:
+            self._status_open_data = get_open_edges(self._faces)
+        return self._status_open_data
+
+    def get_selfintersecting_faces(self):
+        """
+        Obtain and return the potential self intersecting faces indices. If the mesh has n
+        intersecting faces, returns a corresponding 1D array length n faces indices.
+
+        Returns
+        -------
+        status_open_data : numpy.ndarray
+            Open edges data.
+        """
+        if self._status_selfintersecting_data is None:
+            self._status_selfintersecting_data = get_intersecting_triangles(
+                self._vertices, self._faces
+            )
+        return self._status_selfintersecting_data
+
+    @property
+    def status_disconnected_data(self):
+        """Status for connectedness (faces subsets)"""
+        return self._status_disconnected_data
+
+    @property
+    def status_open_data(self):
+        """Status for openness (open edges)"""
+        return self._status_open_data
+
+    @property
+    def status_selfintersecting(self):
+        """Is-selfintersecting boolean check"""
+        return self._status_selfintersecting
+
+    @property
+    def status_selfintersecting_data(self):
+        """return self-intersecting faces"""
+        return self._status_selfintersecting_data
+
+    @property
+    def _barycenter(self):
+        """Object barycenter."""
+        return self._get_barycenter(
+            self._position, self._orientation, self._vertices, self._faces
+        )
+
+    @property
+    def barycenter(self):
+        """Object barycenter."""
+        return np.squeeze(self._barycenter)
+
+    @staticmethod
+    def _get_barycenter(position, orientation, vertices, faces):
+        """Returns the barycenter of a tetrahedron."""
+        centroid = (
+            np.array([0.0, 0.0, 0.0])
+            if vertices is None
+            else calculate_centroid(vertices, faces)
+        )
+        return orientation.apply(centroid) + position
+
+    def _input_check(self, vertices, faces):
+        """input checks here ?"""
+        # no. vertices must exceed largest triangle index
+        # not all vertices can lie in a plane
+        # unique vertices ?
+        # do validation checks
+        if vertices is None:
+            msg = f"Parameter `vertices` of {self} must be set."
+            raise MagpylibMissingInput(msg)
+        if faces is None:
+            msg = f"Parameter `faces` of {self} must be set."
+            raise MagpylibMissingInput(msg)
+        verts = check_format_input_vector(
+            vertices,
+            dims=(2,),
+            shape_m1=3,
+            sig_name="TriangularMesh.vertices",
+            sig_type="array_like (list, tuple, ndarray) of shape (n,3)",
+        )
+        trias = check_format_input_vector(
+            faces,
+            dims=(2,),
+            shape_m1=3,
+            sig_name="TriangularMesh.faces",
+            sig_type="array_like (list, tuple, ndarray) of shape (n,3)",
+        ).astype(int)
+        try:
+            verts[trias]
+        except IndexError as e:
+            msg = "Some `faces` indices do not match with `vertices` array"
+            raise IndexError(msg) from e
+        return verts, trias
+
+    def to_TriangleCollection(self):
+        """Return a Collection of Triangle objects from the current TriangularMesh"""
+        tris = [Triangle(polarization=self.polarization, vertices=v) for v in self.mesh]
+        coll = Collection(tris)
+        coll.position = self.position
+        coll.orientation = self.orientation
+        # pylint: disable=no-member
+        coll.style.update(self.style.as_dict(), _match_properties=False)
+        return coll
+
+    @classmethod
+    def from_ConvexHull(
+        cls,
+        position=(0, 0, 0),
+        orientation=None,
+        points=None,
+        polarization=None,
+        magnetization=None,
+        check_open="warn",
+        check_disconnected="warn",
+        reorient_faces=True,
+        style=None,
+        **kwargs,
+    ):
+        """Create a TriangularMesh magnet from a point cloud via its convex hull.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        position: array_like, shape (3,) or (m,3)
+            Object position(s) in the global coordinates in units of m. For m>1, the
+            `position` and `orientation` attributes together represent an object path.
+
+        orientation: scipy `Rotation` object with length 1 or m, default=`None`
+            Object orientation(s) in the global coordinates. `None` corresponds to
+            a unit-rotation. For m>1, the `position` and `orientation` attributes
+            together represent an object path.
+
+        points: ndarray, shape (n,3)
+            Point cloud from which the convex hull is computed.
+
+        polarization: array_like, shape (3,), default=`None`
+            Magnetic polarization vector J = mu0*M in units of T,
+            given in the local object coordinates (rotates with object).
+
+        magnetization: array_like, shape (3,), default=`None`
+            Magnetization vector M = J/mu0 in units of A/m,
+            given in the local object coordinates (rotates with object).
+
+        reorient_faces: bool, default=`True`
+            In a properly oriented mesh, all faces must be oriented outwards.
+            If `True`, check and fix the orientation of each triangle.
+
+        check_open: {'warn', 'raise', 'ignore'}, default='warn'
+            Only a closed mesh guarantees a physical magnet.
+            If the mesh is open and "warn", a warning is issued.
+            If the mesh is open and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_disconnected: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is disconnected and "warn", a warning is issued.
+            If the mesh is disconnected and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_selfintersecting: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is self-intersecting and "warn", a warning is issued.
+            If the mesh is self-intersecting and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        parent: `Collection` object or `None`
+            The object is a child of it's parent collection.
+
+        style: dict
+            Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+            using style underscore magic, e.g. `style_color='red'`.
+
+        Notes
+        -----
+        Faces are automatically reoriented since `scipy.spatial.ConvexHull` objects do not
+        guarantee that the faces are all pointing outwards. A mesh validation is also performed.
+
+        Returns
+        -------
+        magnet source: `TriangularMesh` object
+        """
+        return cls(
+            position=position,
+            orientation=orientation,
+            vertices=points,
+            faces=ConvexHull(points).simplices,
+            polarization=polarization,
+            magnetization=magnetization,
+            reorient_faces=reorient_faces,
+            check_open=check_open,
+            check_disconnected=check_disconnected,
+            style=style,
+            **kwargs,
+        )
+
+    @classmethod
+    def from_pyvista(
+        cls,
+        position=(0, 0, 0),
+        orientation=None,
+        polydata=None,
+        polarization=None,
+        magnetization=None,
+        check_open="warn",
+        check_disconnected="warn",
+        reorient_faces=True,
+        style=None,
+        **kwargs,
+    ):
+        """Create a TriangularMesh magnet from a pyvista PolyData mesh object.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        position: array_like, shape (3,) or (m,3)
+            Object position(s) in the global coordinates in units of m. For m>1, the
+            `position` and `orientation` attributes together represent an object path.
+
+        orientation: scipy `Rotation` object with length 1 or m, default=`None`
+            Object orientation(s) in the global coordinates. `None` corresponds to
+            a unit-rotation. For m>1, the `position` and `orientation` attributes
+            together represent an object path.
+
+        polydata: pyvista.core.pointset.PolyData object
+            A valid pyvista Polydata mesh object. (e.g. `pyvista.Sphere()`)
+
+        polarization: array_like, shape (3,), default=`None`
+            Magnetic polarization vector J = mu0*M in units of T,
+            given in the local object coordinates (rotates with object).
+
+        magnetization: array_like, shape (3,), default=`None`
+            Magnetization vector M = J/mu0 in units of A/m,
+            given in the local object coordinates (rotates with object).
+
+        reorient_faces: bool, default=`True`
+            In a properly oriented mesh, all faces must be oriented outwards.
+            If `True`, check and fix the orientation of each triangle.
+
+        check_open: {'warn', 'raise', 'ignore'}, default='warn'
+            Only a closed mesh guarantees a physical magnet.
+            If the mesh is open and "warn", a warning is issued.
+            If the mesh is open and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_disconnected: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is disconnected and "warn", a warning is issued.
+            If the mesh is disconnected and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_selfintersecting: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is self-intersecting and "warn", a warning is issued.
+            If the mesh is self-intersecting and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        parent: `Collection` object or `None`
+            The object is a child of it's parent collection.
+
+        style: dict
+            Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+            using style underscore magic, e.g. `style_color='red'`.
+
+        Notes
+        -----
+        Faces are automatically reoriented since `pyvista.core.pointset.PolyData` objects do not
+        guarantee that the faces are all pointing outwards. A mesh validation is also performed.
+
+        Returns
+        -------
+        magnet source: `TriangularMesh` object
+        """
+        # pylint: disable=import-outside-toplevel
+        try:
+            import pyvista
+        except ImportError as missing_module:  # pragma: no cover
+            msg = """In order load pyvista Polydata objects, you first need to install pyvista via pip
+                or conda, see https://docs.pyvista.org/getting-started/installation.html"""
+            raise ModuleNotFoundError(msg) from missing_module
+        if not isinstance(polydata, pyvista.core.pointset.PolyData):
+            msg = (
+                "The `polydata` parameter must be an instance of `pyvista.core.pointset.PolyData`, "
+                f"received {polydata!r} instead"
+            )
+            raise TypeError(msg)
+        polydata = polydata.triangulate()
+        vertices = polydata.points
+        faces = polydata.faces.reshape(-1, 4)[:, 1:]
+
+        return cls(
+            position=position,
+            orientation=orientation,
+            vertices=vertices,
+            faces=faces,
+            polarization=polarization,
+            magnetization=magnetization,
+            reorient_faces=reorient_faces,
+            check_open=check_open,
+            check_disconnected=check_disconnected,
+            style=style,
+            **kwargs,
+        )
+
+    @classmethod
+    def from_triangles(
+        cls,
+        position=(0, 0, 0),
+        orientation=None,
+        triangles=None,
+        polarization=None,
+        magnetization=None,
+        reorient_faces=True,
+        check_open="warn",
+        check_disconnected="warn",
+        style=None,
+        **kwargs,
+    ):
+        """Create a TriangularMesh magnet from a list or Collection of Triangle objects.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        position: array_like, shape (3,) or (m,3)
+            Object position(s) in the global coordinates in units of m. For m>1, the
+            `position` and `orientation` attributes together represent an object path.
+
+        orientation: scipy `Rotation` object with length 1 or m, default=`None`
+            Object orientation(s) in the global coordinates. `None` corresponds to
+            a unit-rotation. For m>1, the `position` and `orientation` attributes
+            together represent an object path.
+
+        triangles: list or Collection of Triangle objects
+            Only vertices of Triangle objects are taken, magnetization is ignored.
+
+        polarization: array_like, shape (3,), default=`None`
+            Magnetic polarization vector J = mu0*M in units of T,
+            given in the local object coordinates (rotates with object).
+
+        magnetization: array_like, shape (3,), default=`None`
+            Magnetization vector M = J/mu0 in units of A/m,
+            given in the local object coordinates (rotates with object).
+
+        reorient_faces: bool, default=`True`
+            In a properly oriented mesh, all faces must be oriented outwards.
+            If `True`, check and fix the orientation of each triangle.
+
+        check_open: {'warn', 'raise', 'ignore'}, default='warn'
+            Only a closed mesh guarantees a physical magnet.
+            If the mesh is open and "warn", a warning is issued.
+            If the mesh is open and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_disconnected: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is disconnected and "warn", a warning is issued.
+            If the mesh is disconnected and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_selfintersecting: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is self-intersecting and "warn", a warning is issued.
+            If the mesh is self-intersecting and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        parent: `Collection` object or `None`
+            The object is a child of it's parent collection.
+
+        style: dict
+            Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+            using style underscore magic, e.g. `style_color='red'`.
+
+        Notes
+        -----
+        Faces are automatically reoriented since `pyvista.core.pointset.PolyData` objects do not
+        guarantee that the faces are all pointing outwards. A mesh validation is also performed.
+
+        Returns
+        -------
+        magnet source: `TriangularMesh` object
+        """
+        if not isinstance(triangles, list | Collection):
+            msg = (
+                "The `triangles` parameter must be a list or Collection of `Triangle` objects, "
+                f"\nreceived type {type(triangles)} instead"
+            )
+            raise TypeError(msg)
+        for obj in triangles:
+            if not isinstance(obj, Triangle):
+                msg = (
+                    "All elements of `triangles` must be `Triangle` objects, "
+                    f"\nreceived type {type(obj)} instead"
+                )
+                raise TypeError(msg)
+        mesh = np.array([tria.vertices for tria in triangles])
+        vertices, tr = np.unique(mesh.reshape((-1, 3)), axis=0, return_inverse=True)
+        faces = tr.reshape((-1, 3))
+
+        return cls(
+            position=position,
+            orientation=orientation,
+            vertices=vertices,
+            faces=faces,
+            polarization=polarization,
+            magnetization=magnetization,
+            reorient_faces=reorient_faces,
+            check_open=check_open,
+            check_disconnected=check_disconnected,
+            style=style,
+            **kwargs,
+        )
+
+    @classmethod
+    def from_mesh(
+        cls,
+        position=(0, 0, 0),
+        orientation=None,
+        mesh=None,
+        polarization=None,
+        magnetization=None,
+        reorient_faces=True,
+        check_open="warn",
+        check_disconnected="warn",
+        style=None,
+        **kwargs,
+    ):
+        """Create a TriangularMesh magnet from a mesh input.
+
+        SI units are used for all inputs and outputs.
+
+        Parameters
+        ----------
+        position: array_like, shape (3,) or (m,3)
+            Object position(s) in the global coordinates in units of m. For m>1, the
+            `position` and `orientation` attributes together represent an object path.
+
+        orientation: scipy `Rotation` object with length 1 or m, default=`None`
+            Object orientation(s) in the global coordinates. `None` corresponds to
+            a unit-rotation. For m>1, the `position` and `orientation` attributes
+            together represent an object path.
+
+        mesh: array_like, shape (n,3,3)
+            An array_like of triangular faces that make up a triangular mesh.
+
+        polarization: array_like, shape (3,), default=`None`
+            Magnetic polarization vector J = mu0*M in units of T,
+            given in the local object coordinates (rotates with object).
+
+        magnetization: array_like, shape (3,), default=`None`
+            Magnetization vector M = J/mu0 in units of A/m,
+            given in the local object coordinates (rotates with object).
+
+        reorient_faces: bool, default=`True`
+            In a properly oriented mesh, all faces must be oriented outwards.
+            If `True`, check and fix the orientation of each triangle.
+
+        check_open: {'warn', 'raise', 'ignore'}, default='warn'
+            Only a closed mesh guarantees a physical magnet.
+            If the mesh is open and "warn", a warning is issued.
+            If the mesh is open and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_disconnected: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is disconnected and "warn", a warning is issued.
+            If the mesh is disconnected and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        check_selfintersecting: {'warn', 'raise', 'ignore'}, default='warn'
+            If the mesh is self-intersecting and "warn", a warning is issued.
+            If the mesh is self-intersecting and "raise", a ValueError is raised.
+            If "ignore", no mesh check is performed.
+
+        parent: `Collection` object or `None`
+            The object is a child of it's parent collection.
+
+        style: dict
+            Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+            using style underscore magic, e.g. `style_color='red'`.
+
+        Notes
+        -----
+        Faces are automatically reoriented since `pyvista.core.pointset.PolyData` objects do not
+        guarantee that the faces are all pointing outwards. A mesh validation is also performed.
+
+        Returns
+        -------
+        magnet source: `TriangularMesh` object
+        """
+        mesh = check_format_input_vector2(
+            mesh,
+            shape=[None, 3, 3],
+            param_name="mesh",
+        )
+        vertices, tr = np.unique(mesh.reshape((-1, 3)), axis=0, return_inverse=True)
+        faces = tr.reshape((-1, 3))
+
+        return cls(
+            position=position,
+            orientation=orientation,
+            vertices=vertices,
+            faces=faces,
+            polarization=polarization,
+            magnetization=magnetization,
+            reorient_faces=reorient_faces,
+            check_open=check_open,
+            check_disconnected=check_disconnected,
+            style=style,
+            **kwargs,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        ntri = len(self.faces)
+        return f"{ntri} face{'s'[: ntri ^ 1]}"
diff --git a/src/magpylib/_src/obj_classes/class_misc_CustomSource.py b/src/magpylib/_src/obj_classes/class_misc_CustomSource.py
new file mode 100644
index 000000000..b091da7bd
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_misc_CustomSource.py
@@ -0,0 +1,76 @@
+"""Custom class code"""
+
+from __future__ import annotations
+
+from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+
+
+class CustomSource(BaseSource):
+    """User-defined custom source.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` local object coordinates
+    coincide with the global coordinate system.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    field_func: callable, default=`None`
+        The function for B- and H-field computation must have the two positional arguments
+        `field` and `observers`. With `field='B'` or `field='H'` the B- or H-field in units
+        of T or A/m must be returned respectively. The `observers` argument must
+        accept numpy ndarray inputs of shape (n,3), in which case the returned fields must
+        be numpy ndarrays of shape (n,3) themselves.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    source: `CustomSource` object
+
+    Examples
+    --------
+    With version 4 `CustomSource` objects enable users to define their own source
+    objects, and to embedded them in the Magpylib object oriented interface. In this example
+    we create a source that generates a constant field and evaluate the field at observer
+    position (0.01,0.01,0.01) given in meters:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> def funcBH(field, observers):
+    ...     return np.array([(.01 if field=='B' else .08,0,0)]*len(observers))
+    >>> src = magpy.misc.CustomSource(field_func=funcBH)
+    >>> H = src.getH((.01,.01,.01))
+    >>> print(H)
+    [0.08 0.   0.  ]
+    """
+
+    _editable_field_func = True
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        field_func=None,
+        style=None,
+        **kwargs,
+    ):
+        # init inheritance
+        super().__init__(position, orientation, field_func, style, **kwargs)
diff --git a/src/magpylib/_src/obj_classes/class_misc_Dipole.py b/src/magpylib/_src/obj_classes/class_misc_Dipole.py
new file mode 100644
index 000000000..c65bea8ec
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_misc_Dipole.py
@@ -0,0 +1,114 @@
+"""Dipole class code"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from magpylib._src.display.traces_core import make_Dipole
+from magpylib._src.fields.field_BH_dipole import BHJM_dipole
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+from magpylib._src.style import DipoleStyle
+from magpylib._src.utility import unit_prefix
+
+
+class Dipole(BaseSource):
+    """Magnetic dipole moment.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the dipole is located in the origin of
+    global coordinate system.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3), default=`(0,0,0)`
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    moment: array_like, shape (3,), unit A·m², default=`None`
+        Magnetic dipole moment in units of A·m² given in the local object coordinates.
+        For homogeneous magnets the relation moment=magnetization*volume holds. For
+        current loops the relation moment = current*loop_surface holds.
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Returns
+    -------
+    source: `Dipole` object
+
+    Examples
+    --------
+    `Dipole` objects are magnetic field sources. In this example we compute the H-field in A/m
+    of such a magnetic dipole with a moment of (100,100,100) in units of A·m² at an
+    observer position (.01,.01,.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> src = magpy.misc.Dipole(moment=(10,10,10))
+    >>> H = src.getH((.01,.01,.01))
+    >>> with np.printoptions(precision=0):
+    ...     print(H)
+    [306294. 306294. 306294.]
+    """
+
+    _field_func = staticmethod(BHJM_dipole)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {"moment": 2}
+    _style_class = DipoleStyle
+    get_trace = make_Dipole
+    _autosize = True
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        moment=None,
+        style=None,
+        **kwargs,
+    ):
+        # instance attributes
+        self.moment = moment
+
+        # init inheritance
+        super().__init__(position, orientation, style, **kwargs)
+
+    # property getters and setters
+    @property
+    def moment(self):
+        """Magnetic dipole moment in units of A·m² given in the local object coordinates."""
+        return self._moment
+
+    @moment.setter
+    def moment(self, mom):
+        """Set dipole moment vector, shape (3,), unit A·m²."""
+        self._moment = check_format_input_vector(
+            mom,
+            dims=(1,),
+            shape_m1=3,
+            sig_name="moment",
+            sig_type="array_like (list, tuple, ndarray) with shape (3,)",
+            allow_None=True,
+        )
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        moment = np.array([0.0, 0.0, 0.0]) if self.moment is None else self.moment
+        moment_mag = np.linalg.norm(moment)
+        if moment_mag == 0:
+            return "no moment"
+        return f"moment={unit_prefix(moment_mag)}A·m²"
diff --git a/src/magpylib/_src/obj_classes/class_misc_Triangle.py b/src/magpylib/_src/obj_classes/class_misc_Triangle.py
new file mode 100644
index 000000000..7f8c6e19e
--- /dev/null
+++ b/src/magpylib/_src/obj_classes/class_misc_Triangle.py
@@ -0,0 +1,155 @@
+# pylint: disable=too-many-positional-arguments
+
+"""Magnet Triangle class"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from magpylib._src.display.traces_core import make_Triangle
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+from magpylib._src.input_checks import check_format_input_vector
+from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet
+from magpylib._src.style import TriangleStyle
+
+
+class Triangle(BaseMagnet):
+    """Triangular surface with homogeneous magnetic surface charge.
+
+    Can be used as `sources` input for magnetic field computation.
+
+    When `position=(0,0,0)` and `orientation=None` the local object coordinates of the
+    Triangle vertices coincide with the global coordinate system. The geometric
+    center of the Triangle is determined by its vertices.
+
+    SI units are used for all inputs and outputs.
+
+    Parameters
+    ----------
+    position: array_like, shape (3,) or (m,3)
+        Object position(s) in the global coordinates in units of m. For m>1, the
+        `position` and `orientation` attributes together represent an object path.
+
+    orientation: scipy `Rotation` object with length 1 or m, default=`None`
+        Object orientation(s) in the global coordinates. `None` corresponds to
+        a unit-rotation. For m>1, the `position` and `orientation` attributes
+        together represent an object path.
+
+    vertices: ndarray, shape (3,3)
+        Triple of vertices in the local object coordinates.
+
+    polarization: array_like, shape (3,), default=`None`
+        Magnetic polarization vector J = mu0*M in units of T,
+        given in the local object coordinates (rotates with object).The homogeneous surface
+        charge of the Triangle is given by the projection of the polarization on the
+        Triangle normal vector (right-hand-rule).
+
+    magnetization: array_like, shape (3,), default=`None`
+        Magnetization vector M = J/mu0 in units of A/m,
+        given in the local object coordinates (rotates with object).The homogeneous surface
+        charge of the Triangle is given by the projection of the magnetization on the
+        Triangle normal vector (right-hand-rule).
+
+    parent: `Collection` object or `None`
+        The object is a child of it's parent collection.
+
+    style: dict
+        Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or
+        using style underscore magic, e.g. `style_color='red'`.
+
+    Attributes
+    ----------
+    barycenter: array_like, shape (3,)
+        Read only property that returns the geometric barycenter (=center of mass)
+        of the object.
+
+    Returns
+    -------
+    magnet source: `Triangle` object
+
+    Examples
+    --------
+    `Triangle` objects are magnetic field sources. Below we compute the H-field in A/m of a
+    Triangle object with polarization (0.01,0.02,0.03) in units of T, dimensions defined
+    through the vertices (0,0,0), (0.01,0,0) and (0,0.01,0) in units of m at the
+    observer position (0.01,0.01,0.01) given in units of m:
+
+    >>> import numpy as np
+    >>> import magpylib as magpy
+    >>> verts = [(0,0,0), (.01,0,0), (0,.01,0)]
+    >>> src = magpy.misc.Triangle(polarization=(.1,.2,.3), vertices=verts)
+    >>> H = src.getH((.1,.1,.1))
+    >>> with np.printoptions(precision=3):
+    ...     print(H)
+    [18.889 18.889 19.546]
+    """
+
+    _field_func = staticmethod(BHJM_triangle)
+    _field_func_kwargs_ndim: ClassVar[dict[str, int]] = {
+        "polarization": 2,
+        "vertices": 2,
+    }
+    get_trace = make_Triangle
+    _style_class = TriangleStyle
+
+    def __init__(
+        self,
+        position=(0, 0, 0),
+        orientation=None,
+        vertices=None,
+        polarization=None,
+        magnetization=None,
+        style=None,
+        **kwargs,
+    ):
+        self.vertices = vertices
+
+        # init inheritance
+        super().__init__(
+            position, orientation, magnetization, polarization, style, **kwargs
+        )
+
+    # property getters and setters
+    @property
+    def vertices(self):
+        """Object faces"""
+        return self._vertices
+
+    @vertices.setter
+    def vertices(self, val):
+        """Set face vertices (a,b,c), shape (3,3), meter."""
+        self._vertices = check_format_input_vector(
+            val,
+            dims=(2,),
+            shape_m1=3,
+            sig_name="Triangle.vertices",
+            sig_type="array_like (list, tuple, ndarray) of shape (3,3)",
+            allow_None=True,
+        )
+
+    @property
+    def _barycenter(self):
+        """Object barycenter."""
+        return self._get_barycenter(self._position, self._orientation, self._vertices)
+
+    @property
+    def barycenter(self):
+        """Object barycenter."""
+        return np.squeeze(self._barycenter)
+
+    @staticmethod
+    def _get_barycenter(position, orientation, vertices):
+        """Returns the barycenter of the Triangle object."""
+        centroid = (
+            np.array([0.0, 0.0, 0.0]) if vertices is None else np.mean(vertices, axis=0)
+        )
+        return orientation.apply(centroid) + position
+
+    @property
+    def _default_style_description(self):
+        """Default style description text"""
+        if self.vertices is None:
+            return "no vertices"
+        return ""
diff --git a/src/magpylib/_src/style.py b/src/magpylib/_src/style.py
new file mode 100644
index 000000000..f245abf10
--- /dev/null
+++ b/src/magpylib/_src/style.py
@@ -0,0 +1,2348 @@
+"""Collection of classes for display styling."""
+
+# pylint: disable=C0302
+# pylint: disable=too-many-instance-attributes
+# pylint: disable=cyclic-import
+# pylint: disable=too-many-positional-arguments
+from __future__ import annotations
+
+import numpy as np
+
+from magpylib._src.defaults.defaults_utility import (
+    ALLOWED_LINESTYLES,
+    ALLOWED_SYMBOLS,
+    SUPPORTED_PLOTTING_BACKENDS,
+    MagicProperties,
+    color_validator,
+    get_defaults_dict,
+    validate_property_class,
+    validate_style_keys,
+)
+
+ALLOWED_SIZEMODES = ("scaled", "absolute")
+
+
+def get_families(obj):
+    """get obj families"""
+    # pylint: disable=import-outside-toplevel
+    # pylint: disable=possibly-unused-variable
+    # pylint: disable=redefined-outer-name
+    # ruff: noqa: F401, I001, I002
+    from magpylib._src.display.traces_generic import MagpyMarkers as Markers
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent as Current
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet as Magnet
+    from magpylib._src.obj_classes.class_current_Circle import Circle
+    from magpylib._src.obj_classes.class_current_Polyline import Polyline
+    from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid
+    from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder
+    from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment
+    from magpylib._src.obj_classes.class_magnet_Sphere import Sphere
+    from magpylib._src.obj_classes.class_magnet_Tetrahedron import Tetrahedron
+    from magpylib._src.obj_classes.class_magnet_TriangularMesh import TriangularMesh
+    from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource
+    from magpylib._src.obj_classes.class_misc_Dipole import Dipole
+    from magpylib._src.obj_classes.class_misc_Triangle import Triangle
+    from magpylib._src.obj_classes.class_Sensor import Sensor
+    # ruff: enable = F401, I001, I002
+
+    loc = locals()
+    obj_families = []
+    for item, val in loc.items():
+        if not item.startswith("_"):
+            try:
+                if isinstance(obj, val):
+                    obj_families.append(item.lower())
+            except TypeError:
+                pass
+    return obj_families
+
+
+def get_style(obj, default_settings, **kwargs):
+    """Returns default style based on increasing priority:
+    - style from defaults
+    - style from object
+    - style from kwargs arguments
+    """
+    obj_families = get_families(obj)
+    # parse kwargs into style an non-style arguments
+    style_kwargs = kwargs.get("style", {})
+    style_kwargs.update(
+        {k[6:]: v for k, v in kwargs.items() if k.startswith("style") and k != "style"}
+    )
+
+    # retrieve default style dictionary, local import to avoid circular import
+    # pylint: disable=import-outside-toplevel
+
+    default_style = default_settings.display.style
+    base_style_flat = default_style.base.as_dict(flatten=True, separator="_")
+
+    # construct object specific dictionary base on style family and default style
+    for obj_family in obj_families:
+        family_style = getattr(default_style, obj_family, {})
+        if family_style:
+            family_dict = family_style.as_dict(flatten=True, separator="_")
+            base_style_flat.update(
+                {k: v for k, v in family_dict.items() if v is not None}
+            )
+    style_kwargs = validate_style_keys(style_kwargs)
+
+    # create style class instance and update based on precedence
+    style = obj.style.copy()
+    style_kwargs_specific = {
+        k: v for k, v in style_kwargs.items() if k.split("_")[0] in style.as_dict()
+    }
+    style.update(**style_kwargs_specific, _match_properties=True)
+    style.update(**base_style_flat, _match_properties=False, _replace_None_only=True)
+
+    return style
+
+
+class Line(MagicProperties):
+    """Defines line styling properties.
+
+    Parameters
+    ----------
+    style: str, default=None
+        Can be one of:
+        `['solid', '-', 'dashed', '--', 'dashdot', '-.', 'dotted', '.', (0, (1, 1)),
+        'loosely dotted', 'loosely dashdotted']`
+
+    color: str, default=None
+        Line color.
+
+    width: float, default=None
+        Positive number that defines the line width.
+    """
+
+    def __init__(self, style=None, color=None, width=None, **kwargs):
+        super().__init__(style=style, color=color, width=width, **kwargs)
+
+    @property
+    def style(self):
+        """Line style."""
+        return self._style
+
+    @style.setter
+    def style(self, val):
+        assert val is None or val in ALLOWED_LINESTYLES, (
+            f"The `style` property of {type(self).__name__} must be one of "
+            f"{ALLOWED_LINESTYLES},\n"
+            f"but received {val!r} instead."
+        )
+        self._style = val
+
+    @property
+    def color(self):
+        """Line color."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val)
+
+    @property
+    def width(self):
+        """Positive number that defines the line width."""
+        return self._width
+
+    @width.setter
+    def width(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `width` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._width = val
+
+
+class BaseStyle(MagicProperties):
+    """Base class for display styling options of `BaseGeo` objects.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    legend: dict or `Legend` object, default=None
+        Object legend properties when displayed in a plot. Legend has the `{label} ({description})`
+        format.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+    """
+
+    def __init__(
+        self,
+        label=None,
+        description=None,
+        legend=None,
+        color=None,
+        opacity=None,
+        path=None,
+        model3d=None,
+        **kwargs,
+    ):
+        super().__init__(
+            label=label,
+            description=description,
+            legend=legend,
+            color=color,
+            opacity=opacity,
+            path=path,
+            model3d=model3d,
+            **kwargs,
+        )
+
+    @property
+    def label(self):
+        """Label of the class instance, e.g. to be displayed in the legend."""
+        return self._label
+
+    @label.setter
+    def label(self, val):
+        self._label = val if val is None else str(val)
+
+    @property
+    def description(self):
+        """Description with 'text' and 'show' properties."""
+        return self._description
+
+    @description.setter
+    def description(self, val):
+        if isinstance(val, str):
+            self._description = Description(text=val)
+        else:
+            self._description = validate_property_class(
+                val, "description", Description, self
+            )
+
+    @property
+    def legend(self):
+        """Legend with 'show' property."""
+        return self._legend
+
+    @legend.setter
+    def legend(self, val):
+        if isinstance(val, str):
+            self._legend = Legend(text=val)
+        else:
+            self._legend = validate_property_class(val, "legend", Legend, self)
+
+    @property
+    def color(self):
+        """A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val, parent_name=f"{type(self).__name__}")
+
+    @property
+    def opacity(self):
+        """Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent."""
+        return self._opacity
+
+    @opacity.setter
+    def opacity(self, val):
+        assert val is None or (isinstance(val, float | int) and 0 <= val <= 1), (
+            "The `opacity` property must be a value between 0 and 1,\n"
+            f"but received {val!r} instead."
+        )
+        self._opacity = val
+
+    @property
+    def path(self):
+        """An instance of `Path` or dictionary of equivalent key/value pairs, defining the
+        object path marker and path line properties."""
+        return self._path
+
+    @path.setter
+    def path(self, val):
+        self._path = validate_property_class(val, "path", Path, self)
+
+    @property
+    def model3d(self):
+        """3d object representation properties."""
+        return self._model3d
+
+    @model3d.setter
+    def model3d(self, val):
+        self._model3d = validate_property_class(val, "model3d", Model3d, self)
+
+
+class Description(MagicProperties):
+    """Defines properties for a description object.
+
+    Parameters
+    ----------
+    text: str, default=None
+        Object description text.
+
+    show: bool, default=None
+        If True, adds legend entry based on value.
+    """
+
+    def __init__(self, text=None, show=None, **kwargs):
+        super().__init__(text=text, show=show, **kwargs)
+
+    @property
+    def text(self):
+        """Description text."""
+        return self._text
+
+    @text.setter
+    def text(self, val):
+        assert val is None or isinstance(val, str), (
+            f"The `show` property of {type(self).__name__} must be a string,\n"
+            f"but received {val!r} instead."
+        )
+        self._text = val
+
+    @property
+    def show(self):
+        """If True, adds legend entry suffix based on value."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+
+class Legend(MagicProperties):
+    """Defines properties for a legend object.
+
+    Parameters
+    ----------
+    text: str, default=None
+        Object description text.
+
+    show: bool, default=None
+        If True, adds legend entry based on value.
+    """
+
+    def __init__(self, show=None, **kwargs):
+        super().__init__(show=show, **kwargs)
+
+    @property
+    def text(self):
+        """Legend text."""
+        return self._text
+
+    @text.setter
+    def text(self, val):
+        assert val is None or isinstance(val, str), (
+            f"The `show` property of {type(self).__name__} must be a string,\n"
+            f"but received {val!r} instead."
+        )
+        self._text = val
+
+    @property
+    def show(self):
+        """If True, adds legend entry based on value."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+
+class Model3d(MagicProperties):
+    """Defines properties for the 3d model representation of magpylib objects.
+
+    Parameters
+    ----------
+    showdefault: bool, default=True
+        Shows/hides default 3d-model.
+
+    data: dict or list of dicts, default=None
+        A trace or list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+    """
+
+    def __init__(self, showdefault=True, data=None, **kwargs):
+        super().__init__(showdefault=showdefault, data=data, **kwargs)
+
+    @property
+    def showdefault(self):
+        """If True, show default model3d object representation, else hide it."""
+        return self._showdefault
+
+    @showdefault.setter
+    def showdefault(self, val):
+        assert isinstance(val, bool), (
+            f"The `showdefault` property of {type(self).__name__} must be "
+            f"one of `[True, False]`,\n"
+            f"but received {val!r} instead."
+        )
+        self._showdefault = val
+
+    @property
+    def data(self):
+        """Data of 3d object representation (trace or list of traces)."""
+        return self._data
+
+    @data.setter
+    def data(self, val):
+        self._data = self._validate_data(val)
+
+    def _validate_data(self, traces, **kwargs):
+        if traces is None:
+            traces = []
+        elif not isinstance(traces, list | tuple):
+            traces = [traces]
+        new_traces = []
+        for trace_item in traces:
+            trace = trace_item
+            updatefunc = None
+            if not isinstance(trace, Trace3d) and callable(trace):
+                updatefunc = trace
+                trace = Trace3d()
+            if not isinstance(trace, Trace3d):
+                trace = validate_property_class(trace, "data", Trace3d, self)
+            if updatefunc is not None:
+                trace.updatefunc = updatefunc
+            trace = trace.update(kwargs)
+            new_traces.append(trace)
+        return new_traces
+
+    def add_trace(self, trace=None, **kwargs):
+        """Adds user-defined 3d model object which is positioned relatively to the main object to be
+        displayed and moved automatically with it. This feature also allows the user to replace the
+        original 3d representation of the object.
+
+        trace: Trace3d instance, dict or callable
+            Trace object. Can be a `Trace3d` instance or an dictionary with equivalent key/values
+            pairs, or a callable returning the equivalent dictionary.
+
+        backend: str
+            Plotting backend corresponding to the trace. Can be one of
+            `['generic', 'matplotlib', 'plotly']`.
+
+        constructor: str
+            Model constructor function or method to be called to build a 3D-model object
+            (e.g. 'plot_trisurf', 'Mesh3d). Must be in accordance with the given plotting backend.
+
+        args: tuple, default=None
+            Tuple or callable returning a tuple containing positional arguments for building a
+            3D-model object.
+
+        kwargs: dict or callable, default=None
+            Dictionary or callable returning a dictionary containing the keys/values pairs for
+            building a 3D-model object.
+
+        coordsargs: dict, default=None
+            Tells magpylib the name of the coordinate arrays to be moved or rotated,
+                by default: `{"x": "x", "y": "y", "z": "z"}`, if False, object is not rotated.
+
+        show: bool, default=None
+            Shows/hides model3d object based on provided trace.
+
+        scale: float, default=1
+            Scaling factor by which the trace vertices coordinates are multiplied.
+
+        updatefunc: callable, default=None
+            Callable object with no arguments. Should return a dictionary with keys from the
+            trace parameters. If provided, the function is called at `show` time and updates the
+            trace parameters with the output dictionary. This allows to update a trace dynamically
+            depending on class attributes, and postpone the trace construction to when the object is
+            displayed.
+        """
+        self._data += self._validate_data([trace], **kwargs)
+        return self
+
+
+class Trace3d(MagicProperties):
+    """Defines properties for an additional user-defined 3d model object which is positioned
+    relatively to the main object to be displayed and moved automatically with it. This feature
+    also allows the user to replace the original 3d representation of the object.
+
+    Parameters
+    ----------
+    backend: str
+        Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`.
+
+    constructor: str
+        Model constructor function or method to be called to build a 3D-model object
+        (e.g. 'plot_trisurf', 'Mesh3d). Must be in accordance with the given plotting backend.
+
+    args: tuple, default=None
+        Tuple or callable returning a tuple containing positional arguments for building a
+        3D-model object.
+
+    kwargs: dict or callable, default=None
+        Dictionary or callable returning a dictionary containing the keys/values pairs for
+        building a 3D-model object.
+
+    coordsargs: dict, default=None
+        Tells magpylib the name of the coordinate arrays to be moved or rotated,
+            by default: `{"x": "x", "y": "y", "z": "z"}`, if False, object is not rotated.
+
+    show: bool, default=True
+        Shows/hides model3d object based on provided trace.
+
+    scale: float, default=1
+        Scaling factor by which the trace vertices coordinates are multiplied.
+
+    updatefunc: callable, default=None
+        Callable object with no arguments. Should return a dictionary with keys from the
+        trace parameters. If provided, the function is called at `show` time and updates the
+        trace parameters with the output dictionary. This allows to update a trace dynamically
+        depending on class attributes, and postpone the trace construction to when the object is
+        displayed.
+    """
+
+    def __init__(
+        self,
+        backend="generic",
+        constructor=None,
+        args=None,
+        kwargs=None,
+        coordsargs=None,
+        show=True,
+        scale=1,
+        updatefunc=None,
+        **params,
+    ):
+        super().__init__(
+            backend=backend,
+            constructor=constructor,
+            args=args,
+            kwargs=kwargs,
+            coordsargs=coordsargs,
+            show=show,
+            scale=scale,
+            updatefunc=updatefunc,
+            **params,
+        )
+
+    @property
+    def args(self):
+        """Tuple or callable returning a tuple containing positional arguments for building a
+        3D-model object."""
+        return self._args
+
+    @args.setter
+    def args(self, val):
+        if val is not None:
+            test_val = val
+            if callable(val):
+                test_val = val()
+            assert isinstance(test_val, tuple), (
+                "The `trace` input must be a dictionary or a callable returning a dictionary,\n"
+                f"but received {type(val).__name__} instead."
+            )
+        self._args = val
+
+    @property
+    def kwargs(self):
+        """Dictionary or callable returning a dictionary containing the keys/values pairs for
+        building a 3D-model object."""
+        return self._kwargs
+
+    @kwargs.setter
+    def kwargs(self, val):
+        if val is not None:
+            test_val = val
+            if callable(val):
+                test_val = val()
+            assert isinstance(test_val, dict), (
+                "The `kwargs` input must be a dictionary or a callable returning a dictionary,\n"
+                f"but received {type(val).__name__} instead."
+            )
+        self._kwargs = val
+
+    @property
+    def constructor(self):
+        """Model constructor function or method to be called to build a 3D-model object
+        (e.g. 'plot_trisurf', 'Mesh3d). Must be in accordance with the given plotting backend.
+        """
+        return self._constructor
+
+    @constructor.setter
+    def constructor(self, val):
+        assert val is None or isinstance(val, str), (
+            f"The `constructor` property of {type(self).__name__} must be a string,"
+            f"\nbut received {val!r} instead."
+        )
+        self._constructor = val
+
+    @property
+    def show(self):
+        """If True, show default model3d object representation, else hide it."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be "
+            f"one of `[True, False]`,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def scale(self):
+        """Scaling factor by which the trace vertices coordinates are multiplied."""
+        return self._scale
+
+    @scale.setter
+    def scale(self, val):
+        assert isinstance(val, int | float) and val > 0, (  # noqa: PT018
+            f"The `scale` property of {type(self).__name__} must be a strictly positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._scale = val
+
+    @property
+    def coordsargs(self):
+        """Tells magpylib the name of the coordinate arrays to be moved or rotated,
+        by default: `{"x": "x", "y": "y", "z": "z"}`, if False, object is not rotated.
+        """
+        return self._coordsargs
+
+    @coordsargs.setter
+    def coordsargs(self, val):
+        assert val is None or (
+            isinstance(val, dict) and all(key in val for key in "xyz")
+        ), (
+            f"The `coordsargs` property of {type(self).__name__} must be "
+            f"a dictionary with `'x', 'y', 'z'` keys,\n"
+            f"but received {val!r} instead."
+        )
+        self._coordsargs = val
+
+    @property
+    def backend(self):
+        """Plotting backend corresponding to the trace. Can be one of
+        `['generic', 'matplotlib', 'plotly']`."""
+        return self._backend
+
+    @backend.setter
+    def backend(self, val):
+        backends = ["generic", *list(SUPPORTED_PLOTTING_BACKENDS)]
+        assert val is None or val in backends, (
+            f"The `backend` property of {type(self).__name__} must be one of"
+            f"{backends},\n"
+            f"but received {val!r} instead."
+        )
+        self._backend = val
+
+    @property
+    def updatefunc(self):
+        """Callable object with no arguments. Should return a dictionary with keys from the
+        trace parameters. If provided, the function is called at `show` time and updates the
+        trace parameters with the output dictionary. This allows to update a trace dynamically
+        depending on class attributes, and postpone the trace construction to when the object is
+        displayed."""
+        return self._updatefunc
+
+    @updatefunc.setter
+    def updatefunc(self, val):
+        if val is None:
+
+            def val():
+                return {}
+
+        msg = ""
+        valid_props = list(self._property_names_generator())
+        if not callable(val):
+            msg = f"Instead received {type(val)}"
+        else:
+            test_val = val()
+            if not isinstance(test_val, dict):
+                msg = f"but callable returned type {type(test_val)}."
+            else:
+                bad_keys = [k for k in test_val if k not in valid_props]
+                if bad_keys:
+                    msg = f"but invalid output dictionary keys received: {bad_keys}."
+
+        assert msg == "", (
+            f"The `updatefunc` property of {type(self).__name__} must be a callable returning a "
+            f"dictionary with a subset of following keys: {valid_props} keys.\n"
+            f"{msg}"
+        )
+        self._updatefunc = val
+
+
+class Magnetization(MagicProperties):
+    """Defines magnetization styling properties.
+
+    Parameters
+    ----------
+    show : bool, default=None
+        If True show magnetization direction.
+
+    color: dict or MagnetizationColor object, default=None
+        Color properties showing the magnetization direction (for the plotly backend).
+        Only applies if `show=True`.
+
+    arrow: dict or Arrow object, default=None,
+        Arrow properties. Only applies if mode='arrow'.
+
+    mode: {"auto", "arrow", "color", "arrow+color"}, default="auto"
+        Magnetization can be displayed via arrows, color or both. By default `mode='auto'` means
+        that the chosen backend determines which mode is applied by its capability. If the backend
+        can display both and `auto` is chosen, the priority is given to `color`.
+    """
+
+    def __init__(self, show=None, size=None, color=None, mode=None, **kwargs):
+        super().__init__(show=show, size=size, color=color, mode=mode, **kwargs)
+
+    @property
+    def show(self):
+        """If True, show magnetization direction."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            "The `show` input must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def size(self):
+        """Deprecated (please use arrow.size): Arrow size property."""
+        return self.arrow.size
+
+    @size.setter
+    def size(self, val):
+        if val is not None:
+            self.arrow.size = val
+
+    @property
+    def color(self):
+        """Color properties showing the magnetization direction (for the plotly backend).
+        Applies only if `show=True`.
+        """
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = validate_property_class(val, "color", MagnetizationColor, self)
+
+    @property
+    def arrow(self):
+        """`Arrow` object or dict with `show, size, width, style, color` properties/keys."""
+        return self._arrow
+
+    @arrow.setter
+    def arrow(self, val):
+        self._arrow = validate_property_class(val, "magnetization", Arrow, self)
+
+    @property
+    def mode(self):
+        """One of {"auto", "arrow", "color", "arrow+color"}, default="auto"
+        Magnetization can be displayed via arrows, color or both. By default `mode='auto'` means
+        that the chosen backend determines which mode is applied by its capability. If the backend
+        can display both and `auto` is chosen, the priority is given to `color`."""
+        return self._mode
+
+    @mode.setter
+    def mode(self, val):
+        allowed = ("auto", "arrow", "color", "arrow+color", "color+arrow")
+        assert val is None or val in allowed, (
+            f"The `mode` input must None or be one of `{allowed}`,\n"
+            f"but received {val!r} instead."
+        )
+        self._mode = val
+
+
+class MagnetizationColor(MagicProperties):
+    """Defines the magnetization direction color styling properties. (Only relevant for
+    the plotly backend)
+
+    Parameters
+    ----------
+    north: str, default=None
+        Defines the color of the magnetic north pole.
+
+    south: str, default=None
+        Defines the color of the magnetic south pole.
+
+    middle: str, default=None
+        Defines the color between the magnetic poles.
+
+    transition: float, default=None
+        Sets the transition smoothness between poles colors. Can be any value
+        in-between 0 (discrete) and 1(smooth).
+
+    mode: str, default=None
+        Sets the coloring mode for the magnetization.
+        - `'bicolor'`: Only north and south pole colors are shown.
+        - `'tricolor'`: Both pole colors and middle color are shown.
+        - `'tricycle'`: Both pole colors are shown and middle color is replaced by a color cycling
+            through the default color sequence.
+    """
+
+    _allowed_modes = ("bicolor", "tricolor", "tricycle")
+
+    def __init__(
+        self, north=None, south=None, middle=None, transition=None, mode=None, **kwargs
+    ):
+        super().__init__(
+            north=north,
+            middle=middle,
+            south=south,
+            transition=transition,
+            mode=mode,
+            **kwargs,
+        )
+
+    @property
+    def north(self):
+        """Color of the magnetic north pole."""
+        return self._north
+
+    @north.setter
+    def north(self, val):
+        self._north = color_validator(val)
+
+    @property
+    def south(self):
+        """Color of the magnetic south pole."""
+        return self._south
+
+    @south.setter
+    def south(self, val):
+        self._south = color_validator(val)
+
+    @property
+    def middle(self):
+        """Color between the magnetic poles."""
+        return self._middle
+
+    @middle.setter
+    def middle(self, val):
+        self._middle = color_validator(val)
+
+    @property
+    def transition(self):
+        """Sets the transition smoothness between poles colors. Can be any value
+        in-between 0 (discrete) and 1(smooth).
+        """
+        return self._transition
+
+    @transition.setter
+    def transition(self, val):
+        assert val is None or (isinstance(val, float | int) and 0 <= val <= 1), (
+            "color transition must be a value between 0 and 1"
+        )
+        self._transition = val
+
+    @property
+    def mode(self):
+        """Sets the coloring mode for the magnetization.
+        - `'bicolor'`: Only north and south pole colors are shown.
+        - `'tricolor'`: Both pole colors and middle color are shown.
+        - `'tricycle'`: Both pole colors are shown and middle color is replaced by a color cycling
+            through the default color sequence.
+        """
+        return self._mode
+
+    @mode.setter
+    def mode(self, val):
+        assert val is None or val in self._allowed_modes, (
+            f"The `mode` property of {type(self).__name__} must be one of"
+            f"{list(self._allowed_modes)},\n"
+            f"but received {val!r} instead."
+        )
+        self._mode = val
+
+
+class MagnetProperties:
+    """Defines styling properties of homogeneous magnet classes.
+
+    Parameters
+    ----------
+    magnetization: dict or `Magnetization` object, default=None
+        `Magnetization` instance with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+    """
+
+    @property
+    def magnetization(self):
+        """`Magnetization` instance with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._magnetization
+
+    @magnetization.setter
+    def magnetization(self, val):
+        self._magnetization = validate_property_class(
+            val, "magnetization", Magnetization, self
+        )
+
+
+class DefaultMagnet(MagicProperties, MagnetProperties):
+    """Defines styling properties of homogeneous magnet classes.
+
+    Parameters
+    ----------
+    magnetization: dict or Magnetization, default=None
+    """
+
+    def __init__(self, magnetization=None, **kwargs):
+        super().__init__(magnetization=magnetization, **kwargs)
+
+
+class MagnetStyle(BaseStyle, MagnetProperties):
+    """Defines styling properties of homogeneous magnet classes.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    magnetization: dict or Magnetization, default=None
+        Magnetization styling with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+
+class MarkerLineProperties:
+    """Defines styling properties of Markers and Lines."""
+
+    @property
+    def show(self):
+        """Show/hide path.
+        - False: Shows object(s) at final path position and hides paths lines and markers.
+        - True: Shows object(s) shows object paths depending on `line`, `marker` and `frames`
+        parameters.
+        """
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def marker(self):
+        """`Markers` object with 'color', 'symbol', 'size' properties."""
+        return self._marker
+
+    @marker.setter
+    def marker(self, val):
+        self._marker = validate_property_class(val, "marker", Marker, self)
+
+    @property
+    def line(self):
+        """`Line` object with 'color', 'type', 'width' properties."""
+        return self._line
+
+    @line.setter
+    def line(self, val):
+        self._line = validate_property_class(val, "line", Line, self)
+
+
+class GridMesh(MagicProperties, MarkerLineProperties):
+    """Defines styling properties of GridMesh objects
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/hide Lines and Markers
+
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    line: dict or `Line` object, default=None
+        `Line` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+    """
+
+
+class OpenMesh(MagicProperties, MarkerLineProperties):
+    """Defines styling properties of OpenMesh objects
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/hide Lines and Markers
+
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    line: dict or `Line` object, default=None
+        `Line` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+    """
+
+
+class DisconnectedMesh(MagicProperties, MarkerLineProperties):
+    """Defines styling properties of DisconnectedMesh objects
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/hide Lines and Markers
+
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    line: dict or `Line` object, default=None
+        `Line` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    colorsequence: iterable, default=["red", "blue", "green", "cyan", "magenta", "yellow"]
+        An iterable of color values used to cycle through for every disconnected part of
+        disconnected triangular mesh object.
+        A color may be specified by
+      - a hex string (e.g. '#ff0000')
+      - an rgb/rgba string (e.g. 'rgb(255,0,0)')
+      - an hsl/hsla string (e.g. 'hsl(0,100%,50%)')
+      - an hsv/hsva string (e.g. 'hsv(0,100%,100%)')
+      - a named CSS color
+    """
+
+    @property
+    def colorsequence(self):
+        """An iterable of color values used to cycle through for every disconnected part of
+        disconnected triangular mesh object.
+          A color may be specified by
+        - a hex string (e.g. '#ff0000')
+        - an rgb/rgba string (e.g. 'rgb(255,0,0)')
+        - an hsl/hsla string (e.g. 'hsl(0,100%,50%)')
+        - an hsv/hsva string (e.g. 'hsv(0,100%,100%)')
+        - a named CSS color"""
+        return self._colorsequence
+
+    @colorsequence.setter
+    def colorsequence(self, val):
+        if val is not None:
+            name = type(self).__name__
+            try:
+                val = tuple(
+                    color_validator(c, allow_None=False, parent_name=f"{name}")
+                    for c in val
+                )
+            except TypeError as err:
+                msg = (
+                    f"The `colorsequence` property of {name} must be an "
+                    f"iterable of colors but received {val!r} instead"
+                )
+                raise ValueError(msg) from err
+
+        self._colorsequence = val
+
+
+class SelfIntersectingMesh(MagicProperties, MarkerLineProperties):
+    """Defines styling properties of SelfIntersectingMesh objects
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/hide Lines and Markers
+
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    line: dict or `Line` object, default=None
+        `Line` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+    """
+
+
+class TriMesh(MagicProperties):
+    """Defines TriMesh mesh properties.
+
+    Parameters
+    ----------
+    grid: dict or GridMesh,  default=None
+        All mesh vertices and edges of a TriangularMesh object.
+
+    open: dict or OpenMesh,  default=None
+        Shows open mesh vertices and edges of a TriangularMesh object, if any.
+
+    disconnected: dict or DisconnectedMesh, default=None
+        Shows disconnected bodies of a TriangularMesh object, if any.
+
+    selfintersecting: dict or SelfIntersectingMesh, default=None
+        Shows self-intersecting triangles of a TriangularMesh object, if any.
+    """
+
+    @property
+    def grid(self):
+        """GridMesh` instance with `'show'` property
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._grid
+
+    @grid.setter
+    def grid(self, val):
+        self._grid = validate_property_class(val, "grid", GridMesh, self)
+
+    @property
+    def open(self):
+        """OpenMesh` instance with `'show'` property
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._open
+
+    @open.setter
+    def open(self, val):
+        self._open = validate_property_class(val, "open", OpenMesh, self)
+
+    @property
+    def disconnected(self):
+        """`DisconnectedMesh` instance with `'show'` property
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._disconnected
+
+    @disconnected.setter
+    def disconnected(self, val):
+        self._disconnected = validate_property_class(
+            val, "disconnected", DisconnectedMesh, self
+        )
+
+    @property
+    def selfintersecting(self):
+        """`SelfIntersectingMesh` instance with `'show'` property
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._selfintersecting
+
+    @selfintersecting.setter
+    def selfintersecting(self, val):
+        self._selfintersecting = validate_property_class(
+            val, "selfintersecting", SelfIntersectingMesh, self
+        )
+
+
+class Orientation(MagicProperties):
+    """Defines Triangle orientation properties.
+
+    Parameters
+    ----------
+    show: bool, default=True
+        Show/hide orientation symbol.
+
+    size: float, default=1,
+        Size of the orientation symbol
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    offset: float, default=0.1
+        Defines the orientation symbol offset, normal to the triangle surface. Must be a number
+        between [0,1], 0 resulting in the cone/arrow head to be coincident to the triangle surface
+        and 1 with the base.
+
+    symbol: {"cone", "arrow3d"}:
+        Orientation symbol for the triangular faces.
+    """
+
+    _allowed_symbols = ("cone", "arrow3d")
+
+    @property
+    def show(self):
+        """Show/hide arrow."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def size(self):
+        """Positive float for ratio of sensor to canvas size."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `size` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def color(self):
+        """A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val, parent_name=f"{type(self).__name__}")
+
+    @property
+    def offset(self):
+        """Defines the orientation symbol offset, normal to the triangle surface. `offset=0` results
+        in the cone/arrow head to be coincident to the triangle surface and `offset=1` with the
+        base.
+        """
+        return self._offset
+
+    @offset.setter
+    def offset(self, val):
+        assert val is None or (isinstance(val, float | int)), (
+            f"The `offset` property must valid number\nbut received {val!r} instead."
+        )
+        self._offset = val
+
+    @property
+    def symbol(self):
+        """Pixel symbol. Can be one of `("cone", "arrow3d")`."""
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, val):
+        assert val is None or val in self._allowed_symbols, (
+            f"The `symbol` property of {type(self).__name__} must be one of"
+            f"{self._allowed_symbols},\n"
+            f"but received {val!r} instead."
+        )
+        self._symbol = val
+
+
+class TriangleProperties:
+    """Defines Triangle properties.
+
+    Parameters
+    ----------
+    orientation: dict or Orientation,  default=None,
+        Orientation styling of triangles.
+    """
+
+    @property
+    def orientation(self):
+        """`Orientation` instance with `'show'` property
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._orientation
+
+    @orientation.setter
+    def orientation(self, val):
+        self._orientation = validate_property_class(
+            val, "orientation", Orientation, self
+        )
+
+
+class DefaultTriangle(MagicProperties, MagnetProperties, TriangleProperties):
+    """Defines styling properties of the Triangle class.
+
+    Parameters
+    ----------
+    magnetization: dict or Magnetization, default=None
+        Magnetization styling with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+
+    orientation: dict or Orientation,  default=None,
+        Orientation of triangles styling with `'show'`, `'size'`, `'color', `'pivot'`, `'symbol'``
+        properties or a dictionary with equivalent key/value pairs..
+    """
+
+    def __init__(self, magnetization=None, orientation=None, **kwargs):
+        super().__init__(magnetization=magnetization, orientation=orientation, **kwargs)
+
+
+class TriangleStyle(MagnetStyle, TriangleProperties):
+    """Defines styling properties of the Triangle class.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    magnetization: dict or Magnetization, default=None
+        Magnetization styling with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+
+    orientation: dict or Orientation,  default=None,
+        Orientation styling of triangles.
+    """
+
+    def __init__(self, orientation=None, **kwargs):
+        super().__init__(orientation=orientation, **kwargs)
+
+
+class TriangularMeshProperties:
+    """Defines TriangularMesh properties."""
+
+    @property
+    def mesh(self):
+        """`TriMesh` instance with `'show', 'markers', 'line'` properties
+        or a dictionary with equivalent key/value pairs.
+        """
+        return self._mesh
+
+    @mesh.setter
+    def mesh(self, val):
+        self._mesh = validate_property_class(val, "mesh", TriMesh, self)
+
+
+class DefaultTriangularMesh(
+    MagicProperties, MagnetProperties, TriangleProperties, TriangularMeshProperties
+):
+    """Defines styling properties of homogeneous TriangularMesh magnet classes.
+
+    Parameters
+    ----------
+    magnetization: dict or Magnetization, default=None
+        Magnetization styling with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+
+    orientation: dict or Orientation,  default=None
+        Orientation of triangles styling with `'show'`, `'size'`, `'color', `'pivot'`, `'symbol'``
+        properties or a dictionary with equivalent key/value pairs.
+
+    mesh: dict or TriMesh, default=None
+        TriMesh styling properties (e.g. `'grid', 'open', 'disconnected'`)
+    """
+
+    def __init__(self, magnetization=None, orientation=None, mesh=None, **kwargs):
+        super().__init__(
+            magnetization=magnetization, orientation=orientation, mesh=mesh, **kwargs
+        )
+
+
+class TriangularMeshStyle(MagnetStyle, TriangleProperties, TriangularMeshProperties):
+    """Defines styling properties of the TriangularMesh magnet class.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    magnetization: dict or Magnetization, default=None
+        Magnetization styling with `'show'`, `'size'`, `'color'` properties
+        or a dictionary with equivalent key/value pairs.
+
+    orientation: dict or Orientation,  default=None,
+        Orientation styling of triangles.
+
+    mesh: dict or TriMesh,  default=None,
+        mesh styling of triangles.
+    """
+
+    def __init__(self, orientation=None, **kwargs):
+        super().__init__(orientation=orientation, **kwargs)
+
+
+class ArrowCS(MagicProperties):
+    """Defines triple coordinate system arrow properties.
+
+    Parameters
+    ----------
+    x: dict or `ArrowSingle` object, default=None
+        x-direction `Arrowsingle` object or dict with equivalent key/value pairs
+        (e.g. `color`, `show`).
+
+    y: dict or `ArrowSingle` object, default=None
+        y-direction `Arrowsingle` object or dict with equivalent key/value pairs
+        (e.g. `color`, `show`).
+
+    z: dict or `ArrowSingle` object, default=None
+        z-direction `Arrowsingle` object or dict with equivalent key/value pairs
+        (e.g. `color`, `show`).
+    """
+
+    def __init__(self, x=None, y=None, z=None):
+        super().__init__(x=x, y=y, z=z)
+
+    @property
+    def x(self):
+        """
+        `ArrowSingle` object or dict with equivalent key/value pairs (e.g. `color`, `show`).
+        """
+        return self._x
+
+    @x.setter
+    def x(self, val):
+        self._x = validate_property_class(val, "x", ArrowSingle, self)
+
+    @property
+    def y(self):
+        """
+        `ArrowSingle` object or dict with equivalent key/value pairs (e.g. `color`, `show`).
+        """
+        return self._y
+
+    @y.setter
+    def y(self, val):
+        self._y = validate_property_class(val, "y", ArrowSingle, self)
+
+    @property
+    def z(self):
+        """
+        `ArrowSingle` object or dict with equivalent key/value pairs (e.g. `color`, `show`).
+        """
+        return self._z
+
+    @z.setter
+    def z(self, val):
+        self._z = validate_property_class(val, "z", ArrowSingle, self)
+
+
+class ArrowSingle(MagicProperties):
+    """Single coordinate system arrow properties.
+
+    Parameters
+    ----------
+    show: bool, default=True
+        Show/hide arrow.
+
+    color: color, default=None
+        Valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+    """
+
+    def __init__(self, show=True, color=None):
+        super().__init__(show=show, color=color)
+
+    @property
+    def show(self):
+        """Show/hide arrow."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,\n"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def color(self):
+        """A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val, parent_name=f"{type(self).__name__}")
+
+
+class SensorProperties:
+    """Defines the specific styling properties of the Sensor class.
+
+    Parameters
+    ----------
+    size: float, default=None
+        Positive float for ratio of sensor to canvas size.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the sensor size. If 'absolute', the `size` parameters
+        becomes the sensor size in meters.
+
+    pixel: dict, Pixel, default=None
+        `Pixel` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+
+    arrows: dict, ArrowCS, default=None
+        `ArrowCS` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+    """
+
+    @property
+    def size(self):
+        """Positive float for ratio of sensor to canvas size."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `size` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def sizemode(self):
+        """Sizemode of the sensor."""
+        return self._sizemode
+
+    @sizemode.setter
+    def sizemode(self, val):
+        assert val is None or val in ALLOWED_SIZEMODES, (
+            f"The `sizemode` property of {type(self).__name__} must be a one of "
+            f"{ALLOWED_SIZEMODES},\nbut received {val!r} instead."
+        )
+        self._sizemode = val
+
+    @property
+    def pixel(self):
+        """`Pixel` object or dict with equivalent key/value pairs (e.g. `color`, `size`)."""
+        return self._pixel
+
+    @pixel.setter
+    def pixel(self, val):
+        self._pixel = validate_property_class(val, "pixel", Pixel, self)
+
+    @property
+    def arrows(self):
+        """`ArrowCS` object or dict with equivalent key/value pairs (e.g. `color`, `size`)."""
+        return self._arrows
+
+    @arrows.setter
+    def arrows(self, val):
+        self._arrows = validate_property_class(val, "arrows", ArrowCS, self)
+
+
+class DefaultSensor(MagicProperties, SensorProperties):
+    """Defines styling properties of the Sensor class.
+
+    Parameters
+    ----------
+    size: float, default=None
+        Positive float for ratio of sensor to canvas size.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the sensor size. If 'absolute', the `size` parameters
+        becomes the sensor size in meters.
+
+    pixel: dict, Pixel, default=None
+        `Pixel` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+
+    arrows: dict, ArrowCS, default=None
+        `ArrowCS` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+    """
+
+    def __init__(
+        self,
+        size=None,
+        sizemode=None,
+        pixel=None,
+        arrows=None,
+        **kwargs,
+    ):
+        super().__init__(
+            size=size,
+            sizemode=sizemode,
+            pixel=pixel,
+            arrows=arrows,
+            **kwargs,
+        )
+
+
+class SensorStyle(BaseStyle, SensorProperties):
+    """Defines the styling properties of the Sensor class.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    size: float, default=None
+        Positive float for ratio of sensor size to canvas size.
+
+    pixel: dict, Pixel, default=None
+        `Pixel` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+
+    arrows: dict, ArrowCS, default=None
+        `ArrowCS` object or dict with equivalent key/value pairs (e.g. `color`, `size`).
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+
+class Pixel(MagicProperties):
+    """Defines the styling properties of sensor pixels.
+
+    Parameters
+    ----------
+    size: float, default=1
+        Positive float for relative pixel size.
+        - matplotlib backend: Pixel size is the marker size.
+        - plotly backend: Relative distance to nearest neighbor pixel.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the pixel size. If 'absolute', the `size` parameters
+        becomes the pixel size in meters.
+
+    color: str, default=None
+        Defines the pixel color@property.
+
+    symbol: str, default=None
+        Pixel symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`.
+        Only applies for matplotlib plotting backend.
+    """
+
+    def __init__(self, size=1, sizemode=None, color=None, symbol=None, **kwargs):
+        super().__init__(
+            size=size,
+            sizemode=sizemode,
+            color=color,
+            symbol=symbol,
+            **kwargs,
+        )
+
+    @property
+    def size(self):
+        """Positive float for relative pixel size.
+        - matplotlib backend: Pixel size is the marker size.
+        - plotly backend: Relative distance to nearest neighbor pixel."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"the `size` property of {type(self).__name__} must be a positive number"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def sizemode(self):
+        """Sizemode of the pixel."""
+        return self._sizemode
+
+    @sizemode.setter
+    def sizemode(self, val):
+        assert val is None or val in ALLOWED_SIZEMODES, (
+            f"The `sizemode` property of {type(self).__name__} must be a one of "
+            f"{ALLOWED_SIZEMODES},\nbut received {val!r} instead."
+        )
+        self._sizemode = val
+
+    @property
+    def color(self):
+        """Pixel color."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val, parent_name=f"{type(self).__name__}")
+
+    @property
+    def symbol(self):
+        """Pixel symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`."""
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, val):
+        assert val is None or val in ALLOWED_SYMBOLS, (
+            f"The `symbol` property of {type(self).__name__} must be one of"
+            f"{ALLOWED_SYMBOLS},\n"
+            f"but received {val!r} instead."
+        )
+        self._symbol = val
+
+
+class CurrentProperties:
+    """Defines styling properties of line current classes.
+
+    Parameters
+    ----------
+    arrow: dict or `Arrow` object, default=None
+        `Arrow` object or dict with `show, size, width, style, color` properties/keys.
+
+    line: dict or `Line` object, default=None
+        `Line` object or dict with `show, width, style, color` properties/keys.
+    """
+
+    @property
+    def arrow(self):
+        """`Arrow` object or dict with `show, size, width, style, color` properties/keys."""
+        return self._arrow
+
+    @arrow.setter
+    def arrow(self, val):
+        self._arrow = validate_property_class(val, "current", Arrow, self)
+
+    @property
+    def line(self):
+        """`Line` object or dict with `show, width, style, color` properties/keys."""
+        return self._line
+
+    @line.setter
+    def line(self, val):
+        self._line = validate_property_class(val, "line", CurrentLine, self)
+
+
+class DefaultCurrent(MagicProperties, CurrentProperties):
+    """Defines the specific styling properties of line current classes.
+
+    Parameters
+    ----------
+    arrow: dict or `Arrow`object, default=None
+        `Arrow` object or dict with 'show', 'size' properties/keys.
+    """
+
+    def __init__(self, arrow=None, **kwargs):
+        super().__init__(arrow=arrow, **kwargs)
+
+
+class CurrentStyle(BaseStyle, CurrentProperties):
+    """Defines styling properties of line current classes.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    arrow: dict or `Arrow` object, default=None
+        `Arrow` object or dict with `'show'`, `'size'` properties/keys.
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+
+class Arrow(Line):
+    """Defines styling properties of current arrows.
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/Hide arrow
+
+    size: float, default=None
+        Positive number defining the size of the arrows. Effective value depends on the
+        `sizemode` parameter.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the arrow size. If 'absolute', the `size` parameters
+        becomes the arrow length in meters.
+
+    offset: float, default=0.5
+        Defines the arrow offset. `offset=0` results in the arrow head to be coincident to start
+        of the line, and `offset=1` with the end.
+
+    style: str, default=None
+        Can be one of:
+        `['solid', '-', 'dashed', '--', 'dashdot', '-.', 'dotted', '.', (0, (1, 1)),
+        'loosely dotted', 'loosely dashdotted']`
+
+    color: str, default=None
+        Line color.
+
+    width: float, default=None
+        Positive number that defines the line width.
+    """
+
+    def __init__(self, show=None, size=None, **kwargs):
+        super().__init__(show=show, size=size, **kwargs)
+
+    @property
+    def show(self):
+        """Show/hide arrow showing current direction."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+    @property
+    def size(self):
+        """Positive number defining the size of the arrows."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `size` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def sizemode(self):
+        """Sizemode of the arrows."""
+        return self._sizemode
+
+    @sizemode.setter
+    def sizemode(self, val):
+        assert val is None or val in ALLOWED_SIZEMODES, (
+            f"The `sizemode` property of {type(self).__name__} must be a one of "
+            f"{ALLOWED_SIZEMODES},\nbut received {val!r} instead."
+        )
+        self._sizemode = val
+
+    @property
+    def offset(self):
+        """Defines the arrow offset. `offset=0` results in the arrow head to be coincident to start
+        of the line, and `offset=1` with the end.
+        """
+        return self._offset
+
+    @offset.setter
+    def offset(self, val):
+        assert val is None or ((isinstance(val, float | int)) and 0 <= val <= 1), (
+            "The `offset` property must valid number between 0 and 1\n"
+            f"but received {val!r} instead."
+        )
+        self._offset = val
+
+
+class CurrentLine(Line):
+    """Defines styling properties of current lines.
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/Hide arrow
+
+    style: str, default=None
+        Can be one of:
+        `['solid', '-', 'dashed', '--', 'dashdot', '-.', 'dotted', '.', (0, (1, 1)),
+        'loosely dotted', 'loosely dashdotted']`
+
+    color: str, default=None
+        Line color.
+
+    width: float, default=None
+        Positive number that defines the line width.
+    """
+
+    @property
+    def show(self):
+        """Show/hide current line."""
+        return self._show
+
+    @show.setter
+    def show(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `show` property of {type(self).__name__} must be either True or False,"
+            f"but received {val!r} instead."
+        )
+        self._show = val
+
+
+class Marker(MagicProperties):
+    """Defines styling properties of plot markers.
+
+    Parameters
+    ----------
+    size: float, default=None
+        Marker size.
+    color: str, default=None
+        Marker color.
+    symbol: str, default=None
+        Marker symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`.
+    """
+
+    def __init__(self, size=None, color=None, symbol=None, **kwargs):
+        super().__init__(size=size, color=color, symbol=symbol, **kwargs)
+
+    @property
+    def size(self):
+        """Marker size."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `size` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def color(self):
+        """Marker color."""
+        return self._color
+
+    @color.setter
+    def color(self, val):
+        self._color = color_validator(val)
+
+    @property
+    def symbol(self):
+        """Marker symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`."""
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, val):
+        assert val is None or val in ALLOWED_SYMBOLS, (
+            f"The `symbol` property of {type(self).__name__} must be one of"
+            f"{ALLOWED_SYMBOLS},\n"
+            f"but received {val!r} instead."
+        )
+        self._symbol = val
+
+
+class DefaultMarkers(BaseStyle):
+    """Defines styling properties of the markers trace.
+
+    Parameters
+    ----------
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+    """
+
+    def __init__(self, marker=None, **kwargs):
+        super().__init__(marker=marker, **kwargs)
+
+    @property
+    def marker(self):
+        """`Markers` object with 'color', 'symbol', 'size' properties."""
+        return self._marker
+
+    @marker.setter
+    def marker(self, val):
+        self._marker = validate_property_class(val, "marker", Marker, self)
+
+
+class DipoleProperties:
+    """Defines styling properties of dipoles.
+
+    Parameters
+    ----------
+    size: float
+        Positive value for ratio of dipole size to canvas size.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the dipole size. If 'absolute', the `size` parameters
+        becomes the dipole size in meters.
+
+    pivot: str
+        The part of the arrow that is anchored to the X, Y grid.
+        The arrow rotates about this point. Can be one of `['tail', 'middle', 'tip']`.
+    """
+
+    _allowed_pivots = ("tail", "middle", "tip")
+
+    @property
+    def size(self):
+        """Positive value for ratio of dipole size to canvas size."""
+        return self._size
+
+    @size.setter
+    def size(self, val):
+        assert val is None or (isinstance(val, int | float) and val >= 0), (
+            f"The `size` property of {type(self).__name__} must be a positive number,\n"
+            f"but received {val!r} instead."
+        )
+        self._size = val
+
+    @property
+    def sizemode(self):
+        """Sizemode of the dipole."""
+        return self._sizemode
+
+    @sizemode.setter
+    def sizemode(self, val):
+        assert val is None or val in ALLOWED_SIZEMODES, (
+            f"The `sizemode` property of {type(self).__name__} must be a one of "
+            f"{ALLOWED_SIZEMODES},\nbut received {val!r} instead."
+        )
+        self._sizemode = val
+
+    @property
+    def pivot(self):
+        """The part of the arrow that is anchored to the X, Y grid.
+        The arrow rotates about this point. Can be one of `['tail', 'middle', 'tip']`.
+        """
+        return self._pivot
+
+    @pivot.setter
+    def pivot(self, val):
+        assert val is None or val in (self._allowed_pivots), (
+            f"The `pivot` property of {type(self).__name__} must be one of "
+            f"{self._allowed_pivots},\n"
+            f"but received {val!r} instead."
+        )
+        self._pivot = val
+
+
+class DefaultDipole(MagicProperties, DipoleProperties):
+    """
+    Defines styling properties of dipoles.
+
+    Parameters
+    ----------
+    size: float, default=None
+        Positive float for ratio of dipole size to canvas size.
+
+    sizemode: {'scaled', 'absolute'}, default='scaled'
+        Defines the scale reference for the dipole size. If 'absolute', the `size` parameters
+        becomes the dipole size in meters.
+
+    pivot: str, default=None
+        The part of the arrow that is anchored to the X, Y grid.
+        The arrow rotates about this point. Can be one of `['tail', 'middle', 'tip']`.
+    """
+
+    def __init__(self, size=None, sizemode=None, pivot=None, **kwargs):
+        super().__init__(size=size, sizemode=sizemode, pivot=pivot, **kwargs)
+
+
+class DipoleStyle(BaseStyle, DipoleProperties):
+    """Defines the styling properties of dipole objects.
+
+    Parameters
+    ----------
+    label: str, default=None
+        Label of the class instance, e.g. to be displayed in the legend.
+
+    description: dict or `Description` object, default=None
+        Object description properties.
+
+    color: str, default=None
+        A valid css color. Can also be one of `['r', 'g', 'b', 'y', 'm', 'c', 'k', 'w']`.
+
+    opacity: float, default=None
+        Object opacity between 0 and 1, where 1 is fully opaque and 0 is fully transparent.
+
+    path: dict or `Path` object, default=None
+        An instance of `Path` or dictionary of equivalent key/value pairs, defining the object
+        path marker and path line properties.
+
+    model3d: list of `Trace3d` objects, default=None
+        A list of traces where each is an instance of `Trace3d` or dictionary of equivalent
+        key/value pairs. Defines properties for an additional user-defined model3d object which is
+        positioned relatively to the main object to be displayed and moved automatically with it.
+        This feature also allows the user to replace the original 3d representation of the object.
+
+    size: float, default=None
+        Positive float for ratio of dipole size to canvas size.
+
+    pivot: str, default=None
+        The part of the arrow that is anchored to the X, Y grid.
+        The arrow rotates about this point. Can be one of `['tail', 'middle', 'tip']`.
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+
+class Path(MagicProperties, MarkerLineProperties):
+    """Defines styling properties of an object's path.
+
+    Parameters
+    ----------
+    show: bool, default=None
+        Show/hide path.
+        - False: Shows object(s) at final path position and hides paths lines and markers.
+        - True: Shows object(s) shows object paths depending on `line`, `marker` and `frames`
+        parameters.
+
+    marker: dict or `Markers` object, default=None
+        `Markers` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    line: dict or `Line` object, default=None
+        `Line` object with 'color', 'symbol', 'size' properties, or dictionary with equivalent
+        key/value pairs.
+
+    frames: int or array_like, shape (n,), default=None
+        Show copies of the 3D-model along the given path indices.
+        - integer i: Displays the object(s) at every i'th path position.
+        - array_like, shape (n,), dtype=int: Displays object(s) at given path indices.
+
+    numbering: bool, default=False
+        Show/hide numbering on path positions.
+    """
+
+    def __init__(
+        self, show=None, marker=None, line=None, frames=None, numbering=None, **kwargs
+    ):
+        super().__init__(
+            show=show,
+            marker=marker,
+            line=line,
+            frames=frames,
+            numbering=numbering,
+            **kwargs,
+        )
+
+    @property
+    def frames(self):
+        """Show copies of the 3D-model along the given path indices.
+        - integer i: Displays the object(s) at every i'th path position.
+        - array_like shape (n,) of integers: Displays object(s) at given path indices.
+        """
+        return self._frames
+
+    @frames.setter
+    def frames(self, val):
+        is_valid_path = True
+        if hasattr(val, "__iter__") and not isinstance(val, str):
+            val = tuple(val)
+            if not all(np.issubdtype(type(v), int) for v in val):
+                is_valid_path = False
+        elif not (val is None or np.issubdtype(type(val), int)):
+            is_valid_path = False
+        assert (
+            is_valid_path
+        ), f"""The `frames` property of {type(self).__name__} must be either:
+- integer i: Displays the object(s) at every i'th path position.
+- array_like, shape (n,), dtype=int: Displays object(s) at given path indices.
+but received {val!r} instead"""
+        self._frames = val
+
+    @property
+    def numbering(self):
+        """Show/hide numbering on path positions. Only applies if show=True."""
+        return self._numbering
+
+    @numbering.setter
+    def numbering(self, val):
+        assert val is None or isinstance(val, bool), (
+            f"The `numbering` property of {type(self).__name__} must be one of (True, False),\n"
+            f"but received {val!r} instead."
+        )
+        self._numbering = val
+
+
+class DisplayStyle(MagicProperties):
+    """Base class containing styling properties for all object families. The properties of the
+    sub-classes are set to hard coded defaults at class instantiation.
+
+    Parameters
+    ----------
+    base: dict or `Base` object, default=None
+        Base properties common to all families.
+
+    magnet: dict or `Magnet` object, default=None
+        Magnet properties.
+
+    current: dict or `Current` object, default=None
+        Current properties.
+
+    dipole: dict or `Dipole` object, default=None
+        Dipole properties.
+
+    triangle: dict or `Triangle` object, default=None
+        Triangle properties
+
+    sensor: dict or `Sensor` object, default=None
+        Sensor properties.
+
+    markers: dict or `Markers` object, default=None
+        Markers properties.
+    """
+
+    def __init__(
+        self,
+        base=None,
+        magnet=None,
+        current=None,
+        dipole=None,
+        triangle=None,
+        sensor=None,
+        markers=None,
+        **kwargs,
+    ):
+        super().__init__(
+            base=base,
+            magnet=magnet,
+            current=current,
+            dipole=dipole,
+            triangle=triangle,
+            sensor=sensor,
+            markers=markers,
+            **kwargs,
+        )
+        # self.reset()
+
+    def reset(self):
+        """Resets all nested properties to their hard coded default values."""
+        self.update(get_defaults_dict("display.style"), _match_properties=False)
+        return self
+
+    @property
+    def base(self):
+        """Base properties common to all families."""
+        return self._base
+
+    @base.setter
+    def base(self, val):
+        self._base = validate_property_class(val, "base", BaseStyle, self)
+
+    @property
+    def magnet(self):
+        """Magnet default style class."""
+        return self._magnet
+
+    @magnet.setter
+    def magnet(self, val):
+        self._magnet = validate_property_class(val, "magnet", DefaultMagnet, self)
+
+    @property
+    def triangularmesh(self):
+        """TriangularMesh default style class."""
+        return self._triangularmesh
+
+    @triangularmesh.setter
+    def triangularmesh(self, val):
+        self._triangularmesh = validate_property_class(
+            val, "triangularmesh", DefaultTriangularMesh, self
+        )
+
+    @property
+    def current(self):
+        """Current default style class."""
+        return self._current
+
+    @current.setter
+    def current(self, val):
+        self._current = validate_property_class(val, "current", DefaultCurrent, self)
+
+    @property
+    def dipole(self):
+        """Dipole default style class."""
+        return self._dipole
+
+    @dipole.setter
+    def dipole(self, val):
+        self._dipole = validate_property_class(val, "dipole", DefaultDipole, self)
+
+    @property
+    def triangle(self):
+        """Triangle default style class."""
+        return self._triangle
+
+    @triangle.setter
+    def triangle(self, val):
+        self._triangle = validate_property_class(val, "triangle", DefaultTriangle, self)
+
+    @property
+    def sensor(self):
+        """Sensor default style class."""
+        return self._sensor
+
+    @sensor.setter
+    def sensor(self, val):
+        self._sensor = validate_property_class(val, "sensor", DefaultSensor, self)
+
+    @property
+    def markers(self):
+        """Markers default style class."""
+        return self._markers
+
+    @markers.setter
+    def markers(self, val):
+        self._markers = validate_property_class(val, "markers", DefaultMarkers, self)
diff --git a/src/magpylib/_src/utility.py b/src/magpylib/_src/utility.py
new file mode 100644
index 000000000..6d145d0cf
--- /dev/null
+++ b/src/magpylib/_src/utility.py
@@ -0,0 +1,511 @@
+"""some utility functions"""
+
+# pylint: disable=import-outside-toplevel
+# pylint: disable=cyclic-import
+# import numbers
+from __future__ import annotations
+
+import warnings
+from collections.abc import Callable, Sequence
+from contextlib import contextmanager
+from functools import cache
+from inspect import signature
+from math import log10
+
+import numpy as np
+
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+
+def get_allowed_sources_msg():
+    "Return allowed source message"
+
+    srcs = list(get_registered_sources())
+    return f"""Sources must be either
+- one of type {srcs}
+- Collection with at least one of the above
+- 1D list of the above
+- string {srcs}"""
+
+
+ALLOWED_OBSERVER_MSG = """Observers must be either
+- array_like positions of shape (N1, N2, ..., 3)
+- Sensor object
+- Collection with at least one Sensor
+- 1D list of the above"""
+
+ALLOWED_SENSORS_MSG = """Sensors must be either
+- Sensor object
+- Collection with at least one Sensor
+- 1D list of the above"""
+
+
+def wrong_obj_msg(*objs, allow="sources"):
+    """return error message for wrong object type provided"""
+    assert len(objs) <= 1, "only max one obj allowed"
+    allowed = allow.split("+")
+    prefix = "No" if len(allowed) == 1 else "Bad"
+    msg = f"{prefix} {'/'.join(allowed)} provided"
+    if "sources" in allowed:
+        msg += "\n" + get_allowed_sources_msg()
+    if "observers" in allowed:
+        msg += "\n" + ALLOWED_OBSERVER_MSG
+    if "sensors" in allowed:
+        msg += "\n" + ALLOWED_SENSORS_MSG
+    if objs:
+        obj = objs[0]
+        msg += f"\nreceived {obj!r} of type {type(obj).__name__!r} instead."
+    return msg
+
+
+def format_star_input(inp):
+    """
+    *inputs are always wrapped in tuple. Formats *inputs of form "src", "src, src"
+    but also "[src, src]" or ""(src,src") so that 1D lists/tuples come out.
+    """
+    if len(inp) == 1:
+        return inp[0]
+    return list(inp)
+
+
+def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> list:
+    """tests and flattens potential input sources (sources, Collections, sequences)
+    ### Args:
+    - sources (sequence): input sources
+    ### Returns:
+    - list: flattened, ordered list of sources
+    ### Info:
+    - exits if invalid sources are given
+    """
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+    from magpylib._src.obj_classes.class_Sensor import Sensor
+
+    obj_list = []
+    flatten_collection = "collections" not in allow.split("+")
+    for obj in objects:
+        try:
+            if isinstance(obj, BaseSource | Sensor):
+                obj_list += [obj]
+            elif flatten_collection or isinstance(obj, list | tuple):
+                obj_list += format_obj_input(
+                    *obj,
+                    allow=allow,
+                    warn=warn,
+                )  # recursive flattening
+            else:
+                obj_list += [obj]
+        except Exception as error:
+            raise MagpylibBadUserInput(wrong_obj_msg(obj, allow=allow)) from error
+    return filter_objects(obj_list, allow=allow, warn=False)
+
+
+def format_src_inputs(sources) -> list:
+    """
+    - input: allow only bare src objects or 1D lists/tuple of src and col
+    - out: sources, src_list
+    ### Args:
+    - sources
+    ### Returns:
+    - sources: ordered list of sources
+    - src_list: ordered list of sources with flattened collections
+    ### Info:
+    - raises an error if sources format is bad
+    """
+
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+    from magpylib._src.obj_classes.class_Collection import Collection
+
+    # store all sources here
+    src_list = []
+
+    # if bare source make into list
+    if not isinstance(sources, list | tuple):
+        sources = [sources]
+
+    if not sources:
+        raise MagpylibBadUserInput(wrong_obj_msg(allow="sources"))
+
+    for src in sources:
+        if isinstance(src, Collection):
+            child_sources = format_obj_input(src, allow="sources")
+            if not child_sources:
+                raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources"))
+            src_list += child_sources
+        elif isinstance(src, BaseSource):
+            src_list += [src]
+        else:
+            raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources"))
+    return list(sources), src_list
+
+
+def check_static_sensor_orient(sensors):
+    """test which sensors have a static orientation"""
+    # pylint: disable=protected-access
+    static_sensor_rot = []
+    for sens in sensors:
+        if len(sens._position) == 1:  # no sensor path (sensor is static)
+            static_sensor_rot += [True]
+        else:  # there is a sensor path
+            rot = sens.orientation.as_quat()
+            if np.all(rot == rot[0]):  # path with static orient (e.g. translation)
+                static_sensor_rot += [True]
+            else:  # sensor rotation changes along path
+                static_sensor_rot += [False]
+    return static_sensor_rot
+
+
+def check_duplicates(obj_list: Sequence) -> list:
+    """checks for and eliminates source duplicates in a list of sources
+    ### Args:
+    - obj_list (list): list with source objects
+    ### Returns:
+    - list: obj_list with duplicates removed
+    """
+    obj_list_new = []
+    for src in obj_list:
+        if src not in obj_list_new:
+            obj_list_new += [src]
+
+    if len(obj_list_new) != len(obj_list):
+        warnings.warn("Eliminating duplicates", UserWarning, stacklevel=2)
+
+    return obj_list_new
+
+
+def check_path_format(inp):
+    """check if each object path has same length
+    of obj.pos and obj.rot
+    Parameters
+    ----------
+    inp: single BaseGeo or list of BaseGeo objects
+    Returns
+    -------
+    no return
+    """
+    # pylint: disable=protected-access
+    if not isinstance(inp, list):
+        inp = [inp]
+    result = all(len(obj._position) == len(obj._orientation) for obj in inp)
+
+    if not result:
+        msg = "Bad path format (rot-pos with different lengths)"
+        raise MagpylibBadUserInput(msg)
+
+
+def filter_objects(obj_list, allow="sources+sensors", warn=True):
+    """
+    return only allowed objects - e.g. no sensors. Throw a warning when something is eliminated.
+    """
+    from magpylib._src.obj_classes.class_BaseExcitations import BaseSource
+    from magpylib._src.obj_classes.class_Collection import Collection
+    from magpylib._src.obj_classes.class_Sensor import Sensor
+
+    # select wanted
+    allowed_classes = ()
+    if "sources" in allow.split("+"):
+        allowed_classes += (BaseSource,)
+    if "sensors" in allow.split("+"):
+        allowed_classes += (Sensor,)
+    if "collections" in allow.split("+"):
+        allowed_classes += (Collection,)
+    new_list = []
+    for obj in obj_list:
+        if isinstance(obj, allowed_classes):
+            new_list += [obj]
+        elif warn:
+            msg = f"Warning, cannot add {obj!r} to Collection."
+            warnings.warn(msg, UserWarning, stacklevel=2)
+    return new_list
+
+
+_UNIT_PREFIX = {
+    -24: "y",  # yocto
+    -21: "z",  # zepto
+    -18: "a",  # atto
+    -15: "f",  # femto
+    -12: "p",  # pico
+    -9: "n",  # nano
+    -6: "µ",  # micro
+    -3: "m",  # milli
+    0: "",
+    3: "k",  # kilo
+    6: "M",  # mega
+    9: "G",  # giga
+    12: "T",  # tera
+    15: "P",  # peta
+    18: "E",  # exa
+    21: "Z",  # zetta
+    24: "Y",  # yotta
+}
+
+_UNIT_PREFIX_REVERSED = {v: k for k, v in _UNIT_PREFIX.items()}
+
+
+@cache
+def get_unit_factor(unit_input, *, target_unit, deci_centi=True):
+    """return unit factor based on input and target unit"""
+    if unit_input is None or unit_input == target_unit:
+        return 1
+    pref, suff, factor_power = "", "", None
+    prefs = _UNIT_PREFIX_REVERSED
+    if deci_centi:
+        prefs = {**_UNIT_PREFIX_REVERSED, "d": -1, "c": -2}
+    unit_input_str = str(unit_input)
+    if unit_input_str:
+        if len(unit_input_str) >= 2:
+            pref, *suff = unit_input_str
+            suff = "".join(suff)
+        if suff == target_unit:
+            factor_power = prefs.get(pref, None)
+
+    if factor_power is None or len(unit_input_str) > 2:
+        valid_inputs = [f"{k}{target_unit}" for k in prefs]
+        msg = f"Invalid unit input ({unit_input!r}), must be one of {valid_inputs}"
+        raise ValueError(msg)
+    return 1 / (10**factor_power)
+
+
+def unit_prefix(number, unit="", precision=3, char_between="", as_tuple=False) -> str:
+    """
+    displays a number with given unit and precision and uses unit prefixes for the exponents from
+    yotta (y) to Yocto (Y). If the exponent is smaller or bigger, falls back to scientific notation.
+    Parameters
+    ----------
+    number : int, float
+        can be any number
+    unit : str, optional
+        unit symbol can be any string, by default ""
+    precision : int, optional
+        gives the number of significant digits, by default 3
+    char_between : str, optional
+        character to insert between number of prefix. Can be " " or any string, if a space is wanted
+        before the unit symbol , by default ""
+    as_tuple: bool, optional
+        if True returns (new_number_str, char_between, prefix, unit) tuple
+        else returns the joined string
+    Returns
+    -------
+    str or tuple
+        returns formatted number as string or tuple
+    """
+    digits = int(log10(abs(number))) // 3 * 3 if number != 0 else 0
+    prefix = _UNIT_PREFIX.get(digits, "")
+
+    if prefix == "":
+        digits = 0
+    new_number_str = f"{number / 10**digits:.{precision}g}"
+    res = (new_number_str, char_between, prefix, unit)
+    if as_tuple:
+        return res
+    return "".join(f"{v}" for v in res)
+
+
+def add_iteration_suffix(name):
+    """
+    adds iteration suffix. If name already ends with an integer it will continue iteration
+    examples:
+        'col' -> 'col_01'
+        'col' -> 'col_01'
+        'col1' -> 'col2'
+        'col_02' -> 'col_03'
+    """
+    # pylint: disable=import-outside-toplevel
+    import re
+
+    m = re.search(r"\d+$", name)
+    n = "00"
+    endstr = None
+    midchar = "_" if name[-1] != "_" else ""
+    if m is not None:
+        midchar = ""
+        n = m.group()
+        endstr = -len(n)
+    return f"{name[:endstr]}{midchar}{int(n) + 1:0{len(n)}}"
+
+
+def cart_to_cyl_coordinates(observer):
+    """
+    cartesian observer positions to cylindrical coordinates
+    observer: ndarray, shape (n,3)
+    """
+    x, y, z = observer.T
+    r, phi = np.sqrt(x**2 + y**2), np.arctan2(y, x)
+    return r, phi, z
+
+
+def cyl_field_to_cart(phi, Br, Bphi=None):
+    """
+    transform Br,Bphi to Bx, By
+    """
+    if Bphi is not None:
+        Bx = Br * np.cos(phi) - Bphi * np.sin(phi)
+        By = Br * np.sin(phi) + Bphi * np.cos(phi)
+    else:
+        Bx = Br * np.cos(phi)
+        By = Br * np.sin(phi)
+
+    return Bx, By
+
+
+def rec_obj_remover(parent, child):
+    """remove known child from parent collection"""
+    # pylint: disable=protected-access
+    from magpylib._src.obj_classes.class_Collection import Collection
+
+    for obj in parent:
+        if obj == child:
+            parent._children.remove(child)
+            parent._update_src_and_sens()
+            return True
+        if isinstance(obj, Collection) and rec_obj_remover(obj, child):
+            break
+    return None
+
+
+def get_subclasses(cls, recursive=False):
+    """Return a dictionary of subclasses by name,"""
+    sub_cls = {}
+    for class_ in cls.__subclasses__():
+        sub_cls[class_.__name__] = class_
+        if recursive:
+            sub_cls.update(get_subclasses(class_, recursive=recursive))
+    return sub_cls
+
+
+def get_registered_sources():
+    """Return all registered sources"""
+    # pylint: disable=import-outside-toplevel
+    from magpylib._src.obj_classes.class_BaseExcitations import (
+        BaseCurrent,
+        BaseMagnet,
+        BaseSource,
+    )
+
+    return {
+        k: v
+        for k, v in get_subclasses(BaseSource, recursive=True).items()
+        if v not in (BaseCurrent, BaseMagnet, BaseSource)
+    }
+
+
+def is_notebook() -> bool:  # pragma: no cover
+    """Check if execution is within a IPython environment"""
+    # pylint: disable=import-outside-toplevel
+    try:
+        from IPython import get_ipython
+
+        shell = get_ipython().__class__.__name__
+        if shell == "ZMQInteractiveShell":
+            return True  # Jupyter notebook or qtconsole
+        if shell == "TerminalInteractiveShell":
+            return False  # Terminal running IPython
+        return False  # Other type (?)
+    except NameError:
+        return False  # Probably standard Python interpreter
+
+
+def open_animation(filepath, embed=True):
+    """Display video or gif file using tkinter or IPython"""
+    # pylint: disable=import-outside-toplevel
+    if is_notebook():
+        if str(filepath).lower().endswith(".gif"):
+            from IPython.display import Image as IPyImage
+            from IPython.display import display
+
+            display(IPyImage(data=filepath, embed=embed))
+        elif str(filepath).lower().endswith(".mp4"):
+            from IPython.display import Video, display
+
+            display(Video(data=filepath, embed=embed))
+        else:  # pragma: no cover
+            msg = "Filetype not supported, only 'mp4 or 'gif' allowed"
+            raise TypeError(msg)
+    else:
+        import webbrowser
+
+        webbrowser.open(filepath)
+
+
+@cache
+def has_parameter(func: Callable, param_name: str) -> bool:
+    """Check if input function has a specific parameter"""
+    sig = signature(func)
+    return param_name in sig.parameters
+
+
+def merge_dicts_with_conflict_check(objs, *, target, identifiers, unique_fields):
+    """
+    Merge dictionaries ensuring unique identifier fields don't lead to conflict.
+
+    Parameters
+    ----------
+    objs : list of dicts
+        List of dictionaries to be merged based on identifier fields.
+    target : str
+        The key in the dictionaries whose values are lists to be merged.
+    identifiers : list of str
+        Keys used to identify a unique dictionary.
+    unique_fields : list of str
+        Additional keys that must not conflict across merged dictionaries.
+
+    Returns
+    -------
+    dict of dicts
+        Merged dictionaries with combined `target` lists, ensuring no conflicts
+        in `unique_fields`.
+
+    Raises
+    ------
+    ValueError
+        If a conflict is detected in `unique_fields` for any `identifiers`.
+
+    Notes
+    -----
+    `objs` should be a list of dictionaries. Identifiers determine uniqueness,
+    and merging is done by extending the lists in the `target` key. If any of
+    the `unique_fields` conflict with previously tracked identifiers, a
+    `ValueError` is raised detailing the conflict.
+
+    """
+    merged_dict = {}
+    tracker = {}
+    for obj in objs:
+        key_dict = {k: obj[k] for k in identifiers}
+        key = tuple(key_dict.values())
+        tracker_previous = tracker.get(key)
+        tracker_actual = tuple(obj[field] for field in unique_fields)
+        if key in tracker and tracker_previous != tracker_actual:
+            diff = [
+                f"{f!r} first got {a!r} then {t!r}"
+                for f, a, t in zip(
+                    unique_fields, tracker_actual, tracker_previous, strict=False
+                )
+                if a != t
+            ]
+            msg = f"Conflicting parameters detected for {key_dict}: {', '.join(diff)}."
+            raise ValueError(msg)
+        tracker[key] = tracker_actual
+
+        if key not in merged_dict:
+            merged_dict[key] = obj
+        else:
+            merged_dict[key][target] = list(
+                dict.fromkeys([*merged_dict[key][target], *obj[target]])
+            )
+    return merged_dict
+
+
+@contextmanager
+def style_temp_edit(obj, style_temp, copy=True):
+    """Temporary replace style to allow edits before returning to original state"""
+    # pylint: disable=protected-access
+    orig_style = getattr(obj, "_style", None)
+    try:
+        # temporary replace style attribute
+        obj._style = style_temp
+        if style_temp and copy:
+            # deepcopy style only if obj is in multiple subplots.
+            obj._style = style_temp.copy()
+        yield
+    finally:
+        obj._style = orig_style
diff --git a/src/magpylib/_version.pyi b/src/magpylib/_version.pyi
new file mode 100644
index 000000000..91744f983
--- /dev/null
+++ b/src/magpylib/_version.pyi
@@ -0,0 +1,4 @@
+from __future__ import annotations
+
+version: str
+version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str]
diff --git a/src/magpylib/core/__init__.py b/src/magpylib/core/__init__.py
new file mode 100644
index 000000000..7e24c1e56
--- /dev/null
+++ b/src/magpylib/core/__init__.py
@@ -0,0 +1,31 @@
+"""
+The core sub-package gives direct access to our field implementations.
+"""
+
+from __future__ import annotations
+
+__all__ = [
+    "current_circle_Hfield",
+    "current_polyline_Hfield",
+    "dipole_Hfield",
+    "magnet_cuboid_Bfield",
+    "magnet_cylinder_axial_Bfield",
+    "magnet_cylinder_diametral_Hfield",
+    "magnet_cylinder_segment_Hfield",
+    "magnet_sphere_Bfield",
+    "triangle_Bfield",
+]
+
+from magpylib._src.fields.field_BH_circle import current_circle_Hfield
+from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_Bfield
+from magpylib._src.fields.field_BH_cylinder import (
+    magnet_cylinder_axial_Bfield,
+    magnet_cylinder_diametral_Hfield,
+)
+from magpylib._src.fields.field_BH_cylinder_segment import (
+    magnet_cylinder_segment_Hfield,
+)
+from magpylib._src.fields.field_BH_dipole import dipole_Hfield
+from magpylib._src.fields.field_BH_polyline import current_polyline_Hfield
+from magpylib._src.fields.field_BH_sphere import magnet_sphere_Bfield
+from magpylib._src.fields.field_BH_triangle import triangle_Bfield
diff --git a/src/magpylib/current/__init__.py b/src/magpylib/current/__init__.py
new file mode 100644
index 000000000..8e961ae2a
--- /dev/null
+++ b/src/magpylib/current/__init__.py
@@ -0,0 +1,10 @@
+"""
+The `magpylib.current` subpackage contains all electric current classes.
+"""
+
+from __future__ import annotations
+
+__all__ = ["Circle", "Line", "Loop", "Polyline"]
+
+from magpylib._src.obj_classes.class_current_Circle import Circle, Loop
+from magpylib._src.obj_classes.class_current_Polyline import Line, Polyline
diff --git a/src/magpylib/graphics/__init__.py b/src/magpylib/graphics/__init__.py
new file mode 100644
index 000000000..8bed79222
--- /dev/null
+++ b/src/magpylib/graphics/__init__.py
@@ -0,0 +1,11 @@
+"""
+The `magpylib.display` sub-package provides additional plotting
+features for independent use.
+"""
+
+from __future__ import annotations
+
+__all__ = ["Trace3d", "model3d", "style"]
+
+from magpylib._src.style import Trace3d
+from magpylib.graphics import model3d, style
diff --git a/src/magpylib/graphics/model3d/__init__.py b/src/magpylib/graphics/model3d/__init__.py
new file mode 100644
index 000000000..0566b6906
--- /dev/null
+++ b/src/magpylib/graphics/model3d/__init__.py
@@ -0,0 +1,29 @@
+"""
+The `magpylib.display.plotly` sub-package provides useful functions for
+convenient creation of 3D traces for commonly used objects in the
+library.
+"""
+
+from __future__ import annotations
+
+__all__ = [
+    "make_Arrow",
+    "make_Cuboid",
+    "make_CylinderSegment",
+    "make_Ellipsoid",
+    "make_Prism",
+    "make_Pyramid",
+    "make_Tetrahedron",
+    "make_TriangularMesh",
+]
+
+from magpylib._src.display.traces_base import (
+    make_Arrow,
+    make_Cuboid,
+    make_CylinderSegment,
+    make_Ellipsoid,
+    make_Prism,
+    make_Pyramid,
+    make_Tetrahedron,
+    make_TriangularMesh,
+)
diff --git a/src/magpylib/graphics/style/__init__.py b/src/magpylib/graphics/style/__init__.py
new file mode 100644
index 000000000..405c841ec
--- /dev/null
+++ b/src/magpylib/graphics/style/__init__.py
@@ -0,0 +1,14 @@
+"""
+The `magpylib.display.style` sub-package provides different object styles.
+"""
+
+from __future__ import annotations
+
+__all__ = [
+    "CurrentStyle",
+    "DipoleStyle",
+    "MagnetStyle",
+    "SensorStyle",
+]
+
+from magpylib._src.style import CurrentStyle, DipoleStyle, MagnetStyle, SensorStyle
diff --git a/src/magpylib/magnet/__init__.py b/src/magpylib/magnet/__init__.py
new file mode 100644
index 000000000..28eaf393f
--- /dev/null
+++ b/src/magpylib/magnet/__init__.py
@@ -0,0 +1,21 @@
+"""
+The `magpylib.magnet` subpackage contains all magnet classes.
+"""
+
+from __future__ import annotations
+
+__all__ = [
+    "Cuboid",
+    "Cylinder",
+    "CylinderSegment",
+    "Sphere",
+    "Tetrahedron",
+    "TriangularMesh",
+]
+
+from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid
+from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder
+from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment
+from magpylib._src.obj_classes.class_magnet_Sphere import Sphere
+from magpylib._src.obj_classes.class_magnet_Tetrahedron import Tetrahedron
+from magpylib._src.obj_classes.class_magnet_TriangularMesh import TriangularMesh
diff --git a/src/magpylib/misc/__init__.py b/src/magpylib/misc/__init__.py
new file mode 100644
index 000000000..bdc6d6741
--- /dev/null
+++ b/src/magpylib/misc/__init__.py
@@ -0,0 +1,11 @@
+"""
+The `magpylib.misc` sub-package contains miscellaneous source objects.
+"""
+
+from __future__ import annotations
+
+__all__ = ["CustomSource", "Dipole", "Triangle"]
+
+from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource
+from magpylib._src.obj_classes.class_misc_Dipole import Dipole
+from magpylib._src.obj_classes.class_misc_Triangle import Triangle
diff --git a/src/magpylib/py.typed b/src/magpylib/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/README.md b/tests/README.md
deleted file mode 100644
index 7636b0976..000000000
--- a/tests/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# About tests
-
-The current tests are [standard unit tests](https://en.wikipedia.org/wiki/Unit_testing), running methods of each class with certain inputs, programatically expecting certain ouputs; accounting for certain edge-cases present within the intrinsic nature of magpylib. These make sure that the codebase is behaving as expected whenever changes are introduced.
-
->It is important that for every introduced method there should be at least one test case showcasing its main functionality.
-
-## Implementation
-
-Tests are implemented utilizing [pytest](https://docs.pytest.org/en/latest/). The automation interface [tox](https://tox.readthedocs.io/en/latest/) is utilized to create and install the library under a new environment, making sure the installation process works before running the tests. [With our configuration](../tox.ini), a code coverage html report is also generated when calling tox, demonstrating how much of the codebase has been tested.
-
-To run rests, simply run `pytest` or run the `tox` interface .
-
-```
-$ pytest
-```
-
-```
-$ tox
-```
-
-## Automated Checks
-
-Automated "checks" are done by the continuous integration service [CircleCI](https://circleci.com/) whenever a push is made to any branch on the remote repository at GitHub. This makes sure the Codebase always remains tested. 
-
-CircleCI will utilize [the configuration file](../.circleci/config.yml), running all the steps necessary (seen above) to properly perform the tests in a cloud environment.  
-
-If a test in a latest commit fails in the cloud environment, a ❌ will appear next to responsible branch being tested, letting maintainers know the code is behaving unexpectedly and should be looked into before merged into a development or release branch.
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29bb..30ea4534b 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""tests"""
diff --git a/tests/test_BHMJ_level.py b/tests/test_BHMJ_level.py
new file mode 100644
index 000000000..82f6cc8f3
--- /dev/null
+++ b/tests/test_BHMJ_level.py
@@ -0,0 +1,904 @@
+from __future__ import annotations
+
+import numpy as np
+from numpy.testing import assert_allclose
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.fields.field_BH_circle import BHJM_circle
+from magpylib._src.fields.field_BH_cuboid import BHJM_magnet_cuboid
+from magpylib._src.fields.field_BH_cylinder import BHJM_magnet_cylinder
+from magpylib._src.fields.field_BH_cylinder_segment import BHJM_cylinder_segment
+from magpylib._src.fields.field_BH_dipole import BHJM_dipole
+from magpylib._src.fields.field_BH_polyline import (
+    BHJM_current_polyline,
+    current_vertices_field,
+)
+from magpylib._src.fields.field_BH_sphere import BHJM_magnet_sphere
+from magpylib._src.fields.field_BH_tetrahedron import BHJM_magnet_tetrahedron
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+from magpylib._src.fields.field_BH_triangularmesh import BHJM_magnet_trimesh
+
+#######################################################################################
+#######################################################################################
+#######################################################################################
+
+# NEW V5 BASIC FIELD COMPUTATION TESTS
+
+
+def helper_check_HBMJ_consistency(func, **kw):
+    """
+    helper function to check H,B,M,J field consistencies
+    returns H, B, M, J
+    """
+    B = func(field="B", **kw)
+    H = func(field="H", **kw)
+    M = func(field="M", **kw)
+    J = func(field="J", **kw)
+    np.testing.assert_allclose(M * MU0, J)
+    np.testing.assert_allclose(B, MU0 * H + J)
+    return H, B, M, J
+
+
+def test_BHJM_magnet_cuboid():
+    """test cuboid field"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (1, 2, 3),
+            (1, 2, 3),
+            (1, 2, 3),
+            (1, 2, 3),
+        ]
+    )
+    dim = np.array(
+        [
+            (1, 2, 3),
+            (-1, -2, 2),
+            (1, 2, 2),
+            (0, 2, 2),
+            (1, 2, 3),
+            (3, 3, 3),
+        ]
+    )
+    obs = np.array(
+        [
+            (1, 2, 3),
+            (1, -1, 0),
+            (1, -1, 0),
+            (1, -1, 0),
+            (1, 2, 3),
+            (0, 0, 0),  # inside
+        ]
+    )
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "dimension": dim,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_magnet_cuboid, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [-0.14174376, -0.16976459, -0.20427478],
+        [-0.14174376, -0.16976459, -0.20427478],
+        [0.0, 0.0, 0.0],
+        [0.02596336, 0.04530334, 0.05840059],
+        [0.66666667, 1.33333333, 2.0],
+    ]
+    np.testing.assert_allclose(B, Btest, rtol=1e-5)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [-112796.09804171, -135094.37189185, -162556.70519527],
+        [-112796.09804171, -135094.37189185, -162556.70519527],
+        [0.0, 0.0, 0.0],
+        [20660.98851314, 36051.25202256, 46473.71425434],
+        [-265258.23848649, -530516.47697298, -795774.71545948],
+    ]
+    np.testing.assert_allclose(H, Htest, rtol=1e-5)
+
+    Jtest = np.array([(0, 0, 0)] * 5 + [(1, 2, 3)])
+    np.testing.assert_allclose(J, Jtest, rtol=1e-5)
+
+    # H_inout = BHJM_magnet_cuboid(field="H", in_out="outside", **kw)
+    # Htest_inout = Htest + Jtest / MU0
+    # np.testing.assert_allclose(H_inout, Htest_inout, rtol=1e-5)
+
+
+def test_BHJM_magnet_cylinder():
+    """test cylinder field computation"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (3, 2, -1),
+            (1, 1, 1),
+        ]
+    )
+    dim = np.array(
+        [
+            (1, 2),
+            (2, 2),
+            (1, 2),
+            (3, 3),
+        ]
+    )
+    obs = np.array(
+        [
+            (1, 2, 3),
+            (1, -1, 0),
+            (1, 1, 1),
+            (0, 0, 0),  # inside
+        ]
+    )
+
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "dimension": dim,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_magnet_cylinder, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [-0.36846057, -0.10171405, -0.33006492],
+        [0.05331225, 0.07895873, 0.10406998],
+        [0.64644661, 0.64644661, 0.70710678],
+    ]
+    np.testing.assert_allclose(B, Btest)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [-293211.60229288, -80941.4714998, -262657.31858654],
+        [42424.54100401, 62833.36365626, 82816.25721518],
+        [-281348.8487991, -281348.8487991, -233077.01786129],
+    ]
+    np.testing.assert_allclose(H, Htest)
+
+    Jtest = np.array([(0, 0, 0), (0, 0, 0), (0, 0, 0), (1, 1, 1)])
+    np.testing.assert_allclose(J, Jtest)
+
+    # H_inout = BHJM_magnet_cylinder(field="H", in_out="outside", **kw)
+    # Htest_inout = Htest - Jtest / MU0
+    # np.testing.assert_allclose(H_inout, Htest_inout, rtol=1e-5)
+
+
+def test_BHJM_magnet_sphere():
+    """test BHJM_magnet_sphere"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (2, 3, -1),
+            (2, 3, -1),
+        ]
+    )
+    dia = np.array([1, 2, 3, 4])
+    obs = np.array(
+        [
+            (1, 2, 3),
+            (1, -1, 0),
+            (0, -1, 0),  # inside
+            (1, -1, 0.5),  # inside
+        ]
+    )
+
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "diameter": dia,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_magnet_sphere, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [-0.29462783, -0.05892557, -0.35355339],
+        [1.33333333, 2.0, -0.66666667],
+        [1.33333333, 2.0, -0.66666667],
+    ]
+    np.testing.assert_allclose(B, Btest)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [-234457.37399925, -46891.47479985, -281348.8487991],
+        [-530516.47697298, -795774.71545948, 265258.23848649],
+        [-530516.47697298, -795774.71545948, 265258.23848649],
+    ]
+    np.testing.assert_allclose(H, Htest)
+
+    Jtest = [(0, 0, 0), (0, 0, 0), (2, 3, -1), (2, 3, -1)]
+    np.testing.assert_allclose(J, Jtest)
+
+
+def test_field_cylinder_segment_BH():
+    """CylinderSegment field test"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (2, 3, -1),
+            (2, 3, -1),
+        ]
+    )
+    dim = np.array(
+        [
+            (1, 2, 3, 10, 20),
+            (1, 2, 3, 10, 20),
+            (1, 3, 2, -50, 50),
+            (0.1, 5, 2, 20, 370),
+        ]
+    )
+    obs = np.array(
+        [
+            (1, 2, 3),
+            (1, -1, 0),
+            (0, -1, 0),
+            (1, -1, 0.5),  # inside
+        ]
+    )
+
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "dimension": dim,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_cylinder_segment, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [0.00762186, 0.04194934, -0.01974813],
+        [0.52440702, -0.04650694, 0.09432828],
+        [1.75574175, 2.58945648, -0.19025747],
+    ]
+    np.testing.assert_allclose(B, Btest, rtol=1e-6)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [6065.28627343, 33382.22618218, -15715.05894253],
+        [417309.84428576, -37009.05020239, 75064.06294505],
+        [-194374.5385654, -326700.15326755, 644372.62925584],
+    ]
+    np.testing.assert_allclose(H, Htest, rtol=1e-6)
+
+    Jtest = [(0, 0, 0), (0, 0, 0), (0, 0, 0), (2, 3, -1)]
+    np.testing.assert_allclose(J, Jtest)
+
+
+def test_BHJM_triangle_BH():
+    """Test of triangle field core function"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (2, -1, 1),
+            (1, -1, 2),
+        ]
+    )
+    vert = np.array(
+        [
+            [(0, 0, 0), (0, 1, 0), (1, 0, 0)],
+            [(0, 0, 0), (0, 1, 0), (1, 0, 0)],
+            [(1, 2, 3), (0, 1, -5), (1, 1, 5)],
+            [(1, 2, 2), (0, 1, -1), (3, -1, 1)],
+        ]
+    )
+    obs = np.array(
+        [
+            (1, 1, 1),
+            (1, 1, 1),
+            (1, 1, 1),
+            (2, 3, 1),
+        ]
+    )
+
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "vertices": vert,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_triangle, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [-0.02825571, -0.02825571, -0.04386991],
+        [-0.34647603, 0.29421715, 0.06980312],
+        [0.02041789, 0.05109073, 0.00218011],
+    ]
+    np.testing.assert_allclose(B, Btest, rtol=1e-06)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [-22485.1813849, -22485.1813849, -34910.56834885],
+        [-275716.86458395, 234130.57085866, 55547.55765999],
+        [16248.03897974, 40656.7134656, 1734.8781397],
+    ]
+    np.testing.assert_allclose(H, Htest, rtol=1e-06)
+
+    Jtest = [(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
+    np.testing.assert_allclose(J, Jtest)
+
+
+def test_magnet_tetrahedron_field_BH():
+    """Test of tetrahedron field core function"""
+    pol = np.array(
+        [
+            (0, 0, 0),
+            (1, 2, 3),
+            (-1, 0.5, 0.1),  # inside
+            (2, 2, -1),
+            (3, 2, 1),  # inside
+        ]
+    )
+    vert = np.array(
+        [
+            [(0, 0, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)],
+            [(0, 0, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)],
+            [(-1, 0, -1), (1, 1, -1), (1, -1, -1), (0, 0, 1)],
+            [(-1, 0, -1), (1, 1, -1), (1, -1, -1), (0, 0, 1)],
+            [(-10, 0, -10), (10, 10, -10), (10, -10, -10), (0, 0, 10)],
+        ]
+    )
+    obs = np.array(
+        [
+            (1, 1, 1),
+            (1, 1, 1),
+            (0, 0, 0),
+            (2, 0, 0),
+            (1, 2, 3),
+        ]
+    )
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "vertices": vert,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_magnet_tetrahedron, **kw)
+
+    Btest = [
+        [0.0, 0.0, 0.0],
+        [0.02602367, 0.02081894, 0.0156142],
+        [-0.69704332, 0.20326329, 0.11578416],
+        [0.04004769, -0.03186713, 0.03854207],
+        [2.09887014, 1.42758632, 0.8611617],
+    ]
+    np.testing.assert_allclose(B, Btest, rtol=1e-06)
+
+    Htest = [
+        [0.0, 0.0, 0.0],
+        [20708.97827326, 16567.1826186, 12425.38696395],
+        [241085.26350642, -236135.56979233, 12560.63814427],
+        [31868.94160192, -25359.05664996, 30670.80436549],
+        [-717096.35551784, -455512.33538799, -110484.00786285],
+    ]
+    np.testing.assert_allclose(H, Htest, rtol=1e-06)
+
+    Jtest = [(0, 0, 0), (0, 0, 0), (-1, 0.5, 0.1), (0, 0, 0), (3, 2, 1)]
+    np.testing.assert_allclose(J, Jtest, rtol=1e-06)
+
+
+def test_BHJM_magnet_trimesh_BH():
+    """Test of BHJM_magnet_trimesh core-like function"""
+
+    mesh1 = [
+        [
+            [0.7439252734184265, 0.5922041535377502, 0.30962786078453064],
+            [0.3820107579231262, -0.8248414397239685, -0.416778564453125],
+            [-0.5555410385131836, 0.4872661232948303, -0.6737549901008606],
+        ],
+        [
+            [-0.5555410385131836, 0.4872661232948303, -0.6737549901008606],
+            [0.3820107579231262, -0.8248414397239685, -0.416778564453125],
+            [-0.5703949332237244, -0.25462886691093445, 0.7809056639671326],
+        ],
+        [
+            [0.7439252734184265, 0.5922041535377502, 0.30962786078453064],
+            [-0.5703949332237244, -0.25462886691093445, 0.7809056639671326],
+            [0.3820107579231262, -0.8248414397239685, -0.416778564453125],
+        ],
+        [
+            [0.7439252734184265, 0.5922041535377502, 0.30962786078453064],
+            [-0.5555410385131836, 0.4872661232948303, -0.6737549901008606],
+            [-0.5703949332237244, -0.25462886691093445, 0.7809056639671326],
+        ],
+    ]
+    mesh2 = [  # inside
+        [
+            [0.9744000434875488, 0.15463787317276, 0.16319207847118378],
+            [-0.12062954157590866, -0.8440634608268738, -0.522499144077301],
+            [-0.3775683045387268, 0.7685779929161072, -0.516459047794342],
+        ],
+        [
+            [-0.3775683045387268, 0.7685779929161072, -0.516459047794342],
+            [-0.12062954157590866, -0.8440634608268738, -0.522499144077301],
+            [-0.47620221972465515, -0.0791524201631546, 0.8757661581039429],
+        ],
+        [
+            [0.9744000434875488, 0.15463787317276, 0.16319207847118378],
+            [-0.47620221972465515, -0.0791524201631546, 0.8757661581039429],
+            [-0.12062954157590866, -0.8440634608268738, -0.522499144077301],
+        ],
+        [
+            [0.9744000434875488, 0.15463787317276, 0.16319207847118378],
+            [-0.3775683045387268, 0.7685779929161072, -0.516459047794342],
+            [-0.47620221972465515, -0.0791524201631546, 0.8757661581039429],
+        ],
+    ]
+    mesh = np.array([mesh1, mesh2])
+    pol = np.array([(1, 2, 3), (3, 2, 1)])
+    obs = np.array([(1, 2, 3), (0, 0, 0)])
+    kw = {
+        "observers": obs,
+        "polarization": pol,
+        "mesh": mesh,
+    }
+    H, B, _, J = helper_check_HBMJ_consistency(BHJM_magnet_trimesh, **kw)
+
+    Btest = [
+        [1.54452002e-03, 3.11861149e-03, 4.68477835e-03],
+        [2.00000002e00, 1.33333333e00, 6.66666685e-01],
+    ]
+    np.testing.assert_allclose(B, Btest)
+
+    Htest = [
+        [1229.08998194, 2481.71216888, 3728.02815642],
+        [-795774.70120171, -530516.47792526, -265258.22366805],
+    ]
+    np.testing.assert_allclose(H, Htest)
+
+    Jtest = [(0, 0, 0), (3, 2, 1)]
+    np.testing.assert_allclose(J, Jtest, rtol=1e-06)
+
+
+def test_BHJM_circle():
+    """Test of current circle field core function"""
+    kw = {
+        "observers": np.array([(1, 1, 1), (2, 2, 2), (3, 3, 3)]),
+        "current": np.array([1, 1, 2]) * 1e3,
+        "diameter": np.array([2, 4, 6]),
+    }
+    H, B, M, _ = helper_check_HBMJ_consistency(BHJM_circle, **kw)
+
+    Btest = (
+        np.array(
+            [
+                [0.06235974, 0.06235974, 0.02669778],
+                [0.03117987, 0.03117987, 0.01334889],
+                [0.04157316, 0.04157316, 0.01779852],
+            ]
+        )
+        * 1e-3
+    )
+    np.testing.assert_allclose(B, Btest)
+
+    Htest = (
+        np.array(
+            [
+                [49624.3033947, 49624.3033947, 21245.41908818],
+                [24812.15169735, 24812.15169735, 10622.70954409],
+                [33082.8689298, 33082.8689298, 14163.61272545],
+            ]
+        )
+        * 1e-3
+    )
+    np.testing.assert_allclose(H, Htest)
+
+    Mtest = [(0, 0, 0)] * 3
+    np.testing.assert_allclose(M, Mtest, rtol=1e-06)
+
+
+def test_BHJM_current_polyline():
+    """Test of current polyline field core function"""
+    vert = np.array([(-1.5, 0, 0), (-0.5, 0, 0), (0.5, 0, 0), (1.5, 0, 0)])
+
+    kw = {
+        "observers": np.array([(0, 0, 1)] * 3),
+        "current": np.array([1, 1, 1]),
+        "segment_start": vert[:-1],
+        "segment_end": vert[1:],
+    }
+    H, B, M, _ = helper_check_HBMJ_consistency(BHJM_current_polyline, **kw)
+
+    Btest = (
+        np.array(
+            [
+                [0.0, -0.03848367, 0.0],
+                [0.0, -0.08944272, 0.0],
+                [0.0, -0.03848367, 0.0],
+            ]
+        )
+        * 1e-6
+    )
+    np.testing.assert_allclose(B, Btest, rtol=0, atol=1e-7)
+
+    Htest = (
+        np.array(
+            [
+                [0.0, -30624.33145161, 0.0],
+                [0.0, -71176.25434172, 0.0],
+                [0.0, -30624.33145161, 0.0],
+            ]
+        )
+        * 1e-6
+    )
+    np.testing.assert_allclose(H, Htest, rtol=0, atol=1e-7)
+
+    Mtest = [(0, 0, 0)] * 3
+    np.testing.assert_allclose(M, Mtest, rtol=1e-06)
+
+
+def test_BHJM_dipole():
+    """Test of dipole field core function"""
+    pol = np.array([(0, 0, 1), (1, 0, 1), (-1, 0.321, 0.123)])
+
+    kw = {
+        "observers": np.array([(1, 2, 3), (-1, -2, -3), (3, 3, -1)]),
+        "moment": pol * 4 * np.pi / 3 / MU0,
+    }
+    H, B, M, _ = helper_check_HBMJ_consistency(BHJM_dipole, **kw)
+
+    Btest = [
+        [4.09073329e-03, 8.18146659e-03, 5.90883698e-03],
+        [-9.09051843e-04, 1.09086221e-02, 9.99957028e-03],
+        [-9.32067617e-05, -5.41001702e-03, 8.77626395e-04],
+    ]
+    np.testing.assert_allclose(B, Btest)
+
+    Htest = [
+        [3255.30212351, 6510.60424703, 4702.1030673],
+        [-723.40047189, 8680.8056627, 7957.40519081],
+        [-74.17158426, -4305.1547508, 698.3928945],
+    ]
+    np.testing.assert_allclose(H, Htest)
+
+    Mtest = [(0, 0, 0)] * 3
+    np.testing.assert_allclose(M, Mtest, rtol=1e-06)
+
+
+#######################################################################################
+#######################################################################################
+#######################################################################################
+
+# OTHER TESTS AND V4 TESTS
+
+
+def test_field_loop_specials():
+    """test loop special cases"""
+    cur = np.array([1, 1, 1, 1, 0, 2])
+    dia = np.array([2, 2, 0, 0, 2, 2])
+    obs = np.array([(0, 0, 0), (1, 0, 0), (0, 0, 0), (1, 0, 0), (1, 0, 0), (0, 0, 0)])
+
+    B = (
+        BHJM_circle(
+            field="B",
+            observers=obs,
+            diameter=dia,
+            current=cur,
+        )
+        * 1e6
+    )
+    Btest = [
+        [0, 0, 0.62831853],
+        [0, 0, 0],
+        [0, 0, 0],
+        [0, 0, 0],
+        [0, 0, 0],
+        [0, 0, 1.25663706],
+    ]
+    assert_allclose(B, Btest)
+
+
+def test_field_line_special_cases():
+    """test line current for all cases"""
+
+    c1 = np.array([1])
+    po1 = np.array([(1, 2, 3)])
+    ps1 = np.array([(0, 0, 0)])
+    pe1 = np.array([(2, 2, 2)])
+
+    # only normal
+    B1 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po1,
+            current=c1,
+            segment_start=ps1,
+            segment_end=pe1,
+        )
+        * 1e6
+    )
+    x1 = np.array([[0.02672612, -0.05345225, 0.02672612]])
+    assert_allclose(x1, B1, rtol=1e-6)
+
+    # only on_line
+    po1b = np.array([(1, 1, 1)])
+    B2 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po1b,
+            current=c1,
+            segment_start=ps1,
+            segment_end=pe1,
+        )
+        * 1e6
+    )
+    x2 = np.zeros((1, 3))
+    assert_allclose(x2, B2, rtol=1e-6)
+
+    # only zero-segment
+    B3 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po1,
+            current=c1,
+            segment_start=ps1,
+            segment_end=ps1,
+        )
+        * 1e6
+    )
+    x3 = np.zeros((1, 3))
+    assert_allclose(x3, B3, rtol=1e-6)
+
+    # only on_line and zero_segment
+    c2 = np.array([1] * 2)
+    ps2 = np.array([(0, 0, 0)] * 2)
+    pe2 = np.array([(0, 0, 0), (2, 2, 2)])
+    po2 = np.array([(1, 2, 3), (1, 1, 1)])
+    B4 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po2,
+            current=c2,
+            segment_start=ps2,
+            segment_end=pe2,
+        )
+        * 1e6
+    )
+    x4 = np.zeros((2, 3))
+    assert_allclose(x4, B4, rtol=1e-6)
+
+    # normal + zero_segment
+    po2b = np.array([(1, 2, 3), (1, 2, 3)])
+    B5 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po2b,
+            current=c2,
+            segment_start=ps2,
+            segment_end=pe2,
+        )
+        * 1e6
+    )
+    x5 = np.array([[0, 0, 0], [0.02672612, -0.05345225, 0.02672612]])
+    assert_allclose(x5, B5, rtol=1e-6)
+
+    # normal + on_line
+    pe2b = np.array([(2, 2, 2)] * 2)
+    B6 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po2,
+            current=c2,
+            segment_start=ps2,
+            segment_end=pe2b,
+        )
+        * 1e6
+    )
+    x6 = np.array([[0.02672612, -0.05345225, 0.02672612], [0, 0, 0]])
+    assert_allclose(x6, B6, rtol=1e-6)
+
+    # normal + zero_segment + on_line
+    c4 = np.array([1] * 3)
+    ps4 = np.array([(0, 0, 0)] * 3)
+    pe4 = np.array([(0, 0, 0), (2, 2, 2), (2, 2, 2)])
+    po4 = np.array([(1, 2, 3), (1, 2, 3), (1, 1, 1)])
+    B7 = (
+        BHJM_current_polyline(
+            field="B",
+            observers=po4,
+            current=c4,
+            segment_start=ps4,
+            segment_end=pe4,
+        )
+        * 1e6
+    )
+    x7 = np.array([[0, 0, 0], [0.02672612, -0.05345225, 0.02672612], [0, 0, 0]])
+    assert_allclose(x7, B7, rtol=1e-6)
+
+
+def test_field_loop2():
+    """test if field function accepts correct inputs"""
+    curr = np.array([1])
+    dia = np.array([2])
+    obs = np.array([[0, 0, 0]])
+    B = BHJM_circle(
+        field="B",
+        observers=obs,
+        current=curr,
+        diameter=dia,
+    )
+
+    curr = np.array([1] * 2)
+    dia = np.array([2] * 2)
+    obs = np.array([[0, 0, 0]] * 2)
+    B2 = BHJM_circle(
+        field="B",
+        observers=obs,
+        current=curr,
+        diameter=dia,
+    )
+
+    assert_allclose(B, (B2[0],))
+    assert_allclose(B, (B2[1],))
+
+
+def test_field_line_from_vert():
+    """test the Polyline field from vertex input"""
+    observers = np.array([(1, 2, 2), (1, 2, 3), (-1, 0, -3)])
+    current = np.array([1, 5, -3])
+
+    vertices = np.array(
+        [
+            np.array(
+                [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)]
+            ),
+            np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]),
+            np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]),
+        ],
+        dtype="object",
+    )
+
+    B_vert = current_vertices_field(
+        field="B",
+        observers=observers,
+        vertices=vertices,
+        current=current,
+    )
+
+    B = []
+    for obs, vert, curr in zip(observers, vertices, current, strict=False):
+        p1 = vert[:-1]
+        p2 = vert[1:]
+        po = np.array([obs] * (len(vert) - 1))
+        cu = np.array([curr] * (len(vert) - 1))
+        B += [
+            np.sum(
+                BHJM_current_polyline(
+                    field="B",
+                    observers=po,
+                    current=cu,
+                    segment_start=p1,
+                    segment_end=p2,
+                ),
+                axis=0,
+            )
+        ]
+    B = np.array(B)
+
+    assert_allclose(B_vert, B)
+
+
+def test_field_line_v4():
+    """test current_line_Bfield() for all cases"""
+    cur = np.array([1] * 7)
+    start = np.array([(-1, 0, 0)] * 7)
+    end = np.array([(1, 0, 0), (-1, 0, 0), (1, 0, 0), (-1, 0, 0)] + [(1, 0, 0)] * 3)
+    obs = np.array(
+        [
+            (0, 0, 1),
+            (0, 0, 0),
+            (0, 0, 0),
+            (0, 0, 0),
+            (0, 0, 1e-16),
+            (2, 0, 1),
+            (-2, 0, 1),
+        ]
+    )
+    B = (
+        BHJM_current_polyline(
+            field="B",
+            observers=obs,
+            current=cur,
+            segment_start=start,
+            segment_end=end,
+        )
+        * 1e6
+    )
+    Btest = np.array(
+        [
+            [0, -0.14142136, 0],
+            [0, 0.0, 0],
+            [0, 0.0, 0],
+            [0, 0.0, 0],
+            [0, 0.0, 0],
+            [0, -0.02415765, 0],
+            [0, -0.02415765, 0],
+        ]
+    )
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_triangle5():
+    """special case tests on edges - result is continuous and 0 for vertical component"""
+    btest1 = [
+        [26.29963526814195, 15.319834473660082, 0.0],
+        [54.91549594789228, 41.20535983076747, 0.0],
+        [32.25241487782939, 15.087161660417559, 0.0],
+        [10.110611199952707, -11.41176203622237, 0.0],
+        [-3.8084378251737285, -30.875600143560657, -0.0],
+        [-15.636505140623612, -50.00854548249858, -0.0],
+        [-27.928308992688645, -72.80800891847107, -0.0],
+        [-45.34417750711242, -109.5871836961927, -0.0],
+        [-36.33970306054345, 12.288824457077656, 0.0],
+        [-16.984738462958845, 4.804383318447626, 0.0],
+    ]
+
+    btest2 = [
+        [15.31983447366009, 26.299635268142033, 0.0],
+        [41.20535983076747, 54.91549594789104, 0.0],
+        [-72.61316618947018, 32.25241487782958, 0.0],
+        [-54.07597251255013, 10.110611199952693, 0.0],
+        [-44.104089712909634, -3.808437825173785, -0.0],
+        [-36.78005591314963, -15.636505140623605, -0.0],
+        [-30.143798442143236, -27.92830899268858, -0.0],
+        [-21.886855846306176, -45.34417750711366, -0.0],
+        [12.288824457077965, -36.33970306054315, 0.0],
+        [4.80438331844773, -16.98473846295874, 0.0],
+    ]
+
+    n = 10
+    ts = np.linspace(-1, 6, n)
+    obs1 = np.array([(t, 0, 0) for t in ts])
+    obs2 = np.array([(0, t, 0) for t in ts])
+    mag = np.array([(111, 222, 333)] * n)
+    ver = np.array([[(0, 0, 0), (0, 5, 0), (5, 0, 0)]] * n)
+
+    b1 = (
+        BHJM_triangle(
+            field="H",
+            observers=obs1,
+            polarization=mag,
+            vertices=ver,
+        )
+        * 1e-6
+    )
+    np.testing.assert_allclose(btest1, b1)
+    b2 = (
+        BHJM_triangle(
+            field="H",
+            observers=obs2,
+            polarization=mag,
+            vertices=ver,
+        )
+        * 1e-6
+    )
+    np.testing.assert_allclose(btest2, b2)
+
+
+def test_triangle6():
+    """special case tests on corners - result is nan"""
+    obs1 = np.array([(0, 0, 0)])
+    obs2 = np.array([(0, 5, 0)])
+    obs3 = np.array([(5, 0, 0)])
+    mag = np.array([(1, 2, 3)])
+    ver = np.array([[(0, 0, 0), (0, 5, 0), (5, 0, 0)]])
+    b1 = BHJM_triangle(
+        field="B",
+        observers=obs1,
+        polarization=mag,
+        vertices=ver,
+    )
+    b2 = BHJM_triangle(
+        field="B",
+        observers=obs2,
+        polarization=mag,
+        vertices=ver,
+    )
+    b3 = BHJM_triangle(
+        field="B",
+        observers=obs3,
+        polarization=mag,
+        vertices=ver,
+    )
+    for b in [b1, b2, b3]:
+        np.testing.assert_equal(b, np.array([[np.nan] * 3]))
diff --git a/tests/test_BaseTransform.py b/tests/test_BaseTransform.py
new file mode 100644
index 000000000..6c2bbd9a4
--- /dev/null
+++ b/tests/test_BaseTransform.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+from magpylib._src.obj_classes.class_BaseTransform import apply_move, apply_rotation
+
+# pylint: disable=too-many-positional-arguments
+
+
+@pytest.mark.parametrize(
+    (
+        "description",
+        "old_position",
+        "displacement",
+        "new_position",
+        "start",
+    ),
+    [
+        # SCALAR INPUT
+        ("01_ with start='auto'", (0, 0, 0), (1, 2, 3), (1, 2, 3), "auto"),
+        ("02_ with start=0", (1, 2, 3), (1, 2, 3), (2, 4, 6), 0),
+        ("03_ with start=-1", (2, 4, 6), (1, 2, 3), (3, 6, 9), -1),
+        ("04_ pad behind", (3, 6, 9), (-1, -2, -3), [(3, 6, 9), (2, 4, 6)], 1),
+        (
+            "05_ whole path",
+            [(3, 6, 9), (2, 4, 6)],
+            (-1, -2, -3),
+            [(2, 4, 6), (1, 2, 3)],
+            "auto",
+        ),
+        (
+            "06_ pad before",
+            [(2, 4, 6), (1, 2, 3)],
+            (-1, -2, -3),
+            [(1, 2, 3), (1, 2, 3), (0, 0, 0)],
+            -3,
+        ),
+        (
+            "07_ whole path starting in the middle",
+            [(1, 2, 3), (1, 2, 3), (0, 0, 0)],
+            (1, 2, 3),
+            [(1, 2, 3), (2, 4, 6), (1, 2, 3)],
+            1,
+        ),
+        (
+            "08_ whole path starting in the middle with negative start",
+            [(1, 2, 3), (2, 4, 6), (1, 2, 3)],
+            (1, 2, 3),
+            [(1, 2, 3), (3, 6, 9), (2, 4, 6)],
+            -2,
+        ),
+        # VECTOR INPUT
+        (
+            "17_ vector + start=0: simple append",
+            (0, 0, 0),
+            [(1, 2, 3)],
+            [(0, 0, 0), (1, 2, 3)],
+            "auto",
+        ),
+        (
+            "18_ vector + start in middle: merge",
+            [(0, 0, 0), (1, 2, 3)],
+            [(1, 2, 3)],
+            [(0, 0, 0), (2, 4, 6)],
+            1,
+        ),
+        (
+            "19_ vector + start in middle: merge + pad behind",
+            [(0, 0, 0), (2, 4, 6)],
+            [(-1, -2, -3), (-2, -4, -6)],
+            [(0, 0, 0), (1, 2, 3), (0, 0, 0)],
+            1,
+        ),
+        (
+            "20_ vector + start before: merge + pad before",
+            [(0, 0, 0), (1, 2, 3), (0, 0, 0)],
+            [(1, 2, 3), (1, 2, 3)],
+            [(1, 2, 3), (1, 2, 3), (1, 2, 3), (0, 0, 0)],
+            -4,
+        ),
+    ],
+)
+def test_apply_move(description, old_position, displacement, new_position, start):
+    """v4 path functionality tests"""
+    print(description)
+    s = magpy.Sensor(position=old_position)
+    apply_move(s, displacement, start=start)
+    np.testing.assert_array_equal(s.position, np.array(new_position))
+
+
+@pytest.mark.parametrize(
+    (
+        "description",
+        "old_position",
+        "new_position",
+        "old_orientation_rotvec",
+        "rotvec_to_apply",
+        "new_orientation_rotvec",
+        "start",
+        "anchor",
+    ),
+    [
+        # SCALAR INPUT
+        (
+            "01_ with start='auto'",
+            (0, 0, 0),
+            (0, 0, 0),
+            (0, 0, 0),
+            (0.1, 0.2, 0.3),
+            (0.1, 0.2, 0.3),
+            "auto",
+            None,
+        ),
+        (
+            "02_ with start=0",
+            (0, 0, 0),
+            (0, 0, 0),
+            (0.1, 0.2, 0.3),
+            (0.1, 0.2, 0.3),
+            (0.2, 0.4, 0.6),
+            0,
+            None,
+        ),
+        (
+            "03_ with start=-1",
+            (0, 0, 0),
+            (0, 0, 0),
+            (0.2, 0.4, 0.6),
+            (-0.2, -0.4, -0.6),
+            (0, 0, 0),
+            -1,
+            None,
+        ),
+        (
+            "04_  with anchor",
+            (0, 0, 0),
+            (1, -1, 0),
+            (0, 0, 0),
+            (0, 0, np.pi / 2),
+            (0, 0, np.pi / 2),
+            -1,
+            (1, 0, 0),
+        ),
+        (
+            "05_  pad behind",
+            (1, -1, 0),
+            [(1, -1, 0), (2, 0, 0)],
+            (0, 0, np.pi / 2),
+            (0, 0, np.pi / 2),
+            [(0, 0, np.pi / 2), (0, 0, np.pi)],
+            1,
+            (1, 0, 0),
+        ),
+        (
+            "06_  whole path",
+            [(1, -1, 0), (2, 0, 0)],
+            [(2, 0, 0), (1, 1, 0)],
+            [(0, 0, np.pi / 2), (0, 0, np.pi)],
+            (0, 0, np.pi / 2),
+            [(0, 0, np.pi), (0, 0, -np.pi / 2)],
+            "auto",
+            (1, 0, 0),
+        ),
+        (
+            "07_ pad before",
+            [(2, 0, 0), (1, 1, 0)],
+            [(1, 1, 0), (1, 1, 0), (0, 0, 0)],
+            [(0, 0, np.pi), (0, 0, -np.pi / 2)],
+            (0, 0, np.pi / 2),
+            [(0, 0, -np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+            -3,
+            (1, 0, 0),
+        ),
+        (
+            "08_ whole path starting in the middle",
+            [(1, 1, 0), (1, 1, 0), (0, 0, 0)],
+            [(1, 1, 0), (0, 0, 0), (1, -1, 0)],
+            [(0, 0, -np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+            (0, 0, np.pi / 2),
+            [(0, 0, -np.pi / 2), (0, 0, 0), (0, 0, np.pi / 2)],
+            1,
+            (1, 0, 0),
+        ),
+        (
+            "09_ whole path starting in the middle without anchor",
+            [(1, 1, 0), (0, 0, 0), (1, -1, 0)],
+            [(1, 1, 0), (0, 0, 0), (1, -1, 0)],
+            [(0, 0, -np.pi / 2), (0, 0, 0), (0, 0, np.pi / 2)],
+            ((0, 0, np.pi / 4)),
+            [(0, 0, -np.pi / 2), (0, 0, np.pi / 4), (0, 0, 3 * np.pi / 4)],
+            1,
+            None,
+        ),
+        # VECTOR INPUT
+        (
+            "11_ simple append start=auto behavior",
+            (0, 0, 0),
+            [(0, 0, 0), (1, -1, 0)],
+            (0, 0, 0),
+            [(0, 0, np.pi / 2)],
+            [(0, 0, 0), (0, 0, np.pi / 2)],
+            "auto",
+            (1, 0, 0),
+        ),
+        (
+            "12_ vector + start=0: simple merge",
+            [(0, 0, 0), (1, -1, 0)],
+            [(1, -1, 0), (1, -1, 0)],
+            [(0, 0, 0), (0, 0, np.pi / 2)],
+            [(0, 0, np.pi / 2)],
+            [(0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+            0,
+            (1, 0, 0),
+        ),
+        (
+            "13_ vector + start in middle: merge + pad behind",
+            [(1, -1, 0), (1, -1, 0)],
+            [(1, -1, 0), (1, 1, 0), (1, 1, 0)],
+            [(0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+            [(0, 0, np.pi), (0, 0, np.pi)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, -np.pi / 2)],
+            1,
+            (1, 0, 0),
+        ),
+        (
+            "14_ vector + start before: merge + pad before",
+            [(1, -1, 0), (1, 1, 0), (1, 1, 0)],
+            [(1, -1, 0), (1, 1, 0), (1, 1, 0), (1, 1, 0)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, -np.pi / 2)],
+            [(0, 0, 0), (0, 0, np.pi)],
+            [
+                (0, 0, np.pi / 2),
+                (0, 0, -np.pi / 2),
+                (0, 0, -np.pi / 2),
+                (0, 0, -np.pi / 2),
+            ],
+            -4,
+            (1, 0, 0),
+        ),
+    ],
+)
+def test_apply_rotation(
+    description,
+    old_position,
+    new_position,
+    old_orientation_rotvec,
+    rotvec_to_apply,
+    new_orientation_rotvec,
+    start,
+    anchor,
+):
+    """v4 path functionality tests"""
+    print(description)
+    s = magpy.Sensor(
+        position=old_position, orientation=R.from_rotvec(old_orientation_rotvec)
+    )
+    apply_rotation(s, R.from_rotvec(rotvec_to_apply), start=start, anchor=anchor)
+
+    np.testing.assert_allclose(
+        s.position, np.array(new_position), rtol=1e-05, atol=1e-08
+    )
+    np.testing.assert_allclose(
+        s.orientation.as_matrix(),
+        R.from_rotvec(new_orientation_rotvec).as_matrix(),
+        rtol=1e-05,
+        atol=1e-08,
+    )
diff --git a/tests/test_Collection.py b/tests/test_Collection.py
deleted file mode 100644
index c9d938d7e..000000000
--- a/tests/test_Collection.py
+++ /dev/null
@@ -1,260 +0,0 @@
-import unittest
-from magpylib._lib.classes.collection import Collection
-from magpylib._lib.classes.base import RCS
-from numpy import array, ndarray
-import pytest
-
-
-def test_motion():
-    # Check if rotate() and move() are
-    # behaving as expected for Collection
-    expectedAngles = { "box1": 90,
-                       "box2": 90 }
-    expectedPositions = { "box1": [-4,  2,  6],
-                          "box2": [-4,  4,  4] }
-
-    from magpylib import source,Collection
-    box = source.magnet.Box([1,2,3],[1,2,3],[1,2,3])
-    # box.position # [1,2,3]
-    # box.angle # 0
-    box2 = source.magnet.Box([1,2,3],[1,2,3],[3,2,1])
-    # box2.position # [3,2,1]
-    # box2.angle # 0
-
-
-    col = Collection(box,box2)
-    col.move([1,2,3])
-    col.rotate(90,(0,0,1),[0,0,0])
-    assert box.angle == expectedAngles["box1"]
-    assert all(round(box.position[i]) == expectedPositions["box1"][i] for i in range(0,3))
-    assert box2.angle == expectedAngles["box2"]
-    assert all(round(box2.position[i]) == expectedPositions["box2"][i] for i in range(0,3))
-
-
-def test_addSource_Duplicate():
-    # Check if addSource is  throwing a warning
-    # and ignoring duplicates
-
-    errMsg = "Duplicate/Extra copy on collection detected"
-    from magpylib import source
-    # Setup
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    # Run
-    with pytest.warns(Warning):
-        b0 = source.magnet.Box(mag,dim)
-        col = Collection() 
-        col.addSources([b0,b0])
-        assert len(col.sources) == 1, errMsg
-
-
-def test_addSource_Duplicate_force():
-    # Check if addSource is NOT throwing a warning
-    # and NOT ignoring duplicates when ignore kwarg is given
-    errMsg = "Threw a warning on false statement"
-    errMsg_list = "Extra copy on collection not detected"
-    from magpylib import source
-    import warnings
-    # Setup
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    # Run
-    with pytest.warns(Warning) as record:
-        warnings.warn("Ok so far, check if this is the only warning.", RuntimeWarning)
-        b0 = source.magnet.Box(mag,dim)
-        col = Collection() 
-        col.addSources(b0,b0, dupWarning = False)
-        assert len(record) == 1, errMsg
-        assert len(col.sources) == 2, errMsg_list
-
-
-def test_initialization_Duplicate():
-    # Check if initialization is  throwing a warning
-    # and ignoring duplicates
-    from magpylib import source
-    # Setup
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    # Run
-    with pytest.warns(Warning):
-        b0 = source.magnet.Box(mag,dim)
-        col = Collection( b0,b0) 
-        assert len(col.sources) == 1
-
-
-def test_initialization_Duplicate_force():
-    # Check if addSource is NOT throwing a warning
-    # and NOT ignoring duplicates when ignore kwarg is given
-    errMsg = "Threw a warning on false statement"
-    errMsg_list = "Extra copy on collection not detected"
-    from magpylib import source
-    import warnings
-    # Setup
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    # Run
-    with pytest.warns(Warning) as record:
-        warnings.warn("Ok so far, check if this is the only warning.", RuntimeWarning)
-        b0 = source.magnet.Box(mag,dim)
-        col = Collection( b0,b0, dupWarning = False) 
-        assert len(record) == 1, errMsg
-        assert len(col.sources) == 2, errMsg_list
-
-
-def test_removeSource():
-    # Check if removeSource is removing the provided source 
-    # and the last added source to the Collection
-    from magpylib import source
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    case = unittest.TestCase()
-    ## Define boxes in different position of memory
-    b0 = source.magnet.Box(mag,dim)
-    b1 = source.magnet.Box(mag,dim)
-    b2 = source.magnet.Box(mag,dim)
-    b3 = source.magnet.Box(mag,dim)
-    b4 = source.magnet.Box(mag,dim)
-    b5 = source.magnet.Box(mag,dim)
-    b6 = source.magnet.Box(mag,dim)
-    allBoxes = [b0,b1,b2,b3,b4,b5,b6]
-    removedSet = [b1,b2,b3,b4,b5]
-
-    # Run
-    col1 = Collection(allBoxes)
-    col1.removeSource(b0)
-    col1.removeSource()
-    removedSet = [b1,b2,b3,b4,b5]
-    case.assertCountEqual(removedSet,col1.sources)
-
-
-def test_removeSource_no_index_error():
-    # Check if removeSource is throwing an error for
-    # removing the an idnex that is not there.
-    from magpylib import source
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    with pytest.raises(IndexError):
-        ## Define boxes in different position of memory
-        b0 = source.magnet.Box(mag,dim)
-        # Run
-        col1 = Collection(b0)
-        col1.removeSource(2)
-
-
-def test_removeSource_no_source_error():
-    # Check if removeSource is throwing an error for
-    # removing the a source that is not there.
-    from magpylib import source
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    with pytest.raises(ValueError):
-        ## Define boxes in different position of memory
-        b0 = source.magnet.Box(mag,dim)
-        b1 = source.magnet.Box(mag,dim)
-        b2 = source.magnet.Box(mag,dim)
-        # Run
-        col1 = Collection(b0,b1)
-        col1.removeSource(b2)
-
-
-def test_initialization():
-    # Check if initialization accepts mixed arguments
-    # source objects, list, collection
-    case = unittest.TestCase()
-    from magpylib import source
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    ## Define boxes in different position of memory
-    b0 = source.magnet.Box(mag,dim)
-    b1 = source.magnet.Box(mag,dim)
-    b2 = source.magnet.Box(mag,dim)
-    b3 = source.magnet.Box(mag,dim)
-    b4 = source.magnet.Box(mag,dim)
-    b5 = source.magnet.Box(mag,dim)
-    b6 = source.magnet.Box(mag,dim)
-    allBoxes = [b0,b1,b2,b3,b4,b5,b6]
-    col1 = Collection( b0,b1 )
-    col2 = Collection([b2,b3])
-    col3 = Collection(col1,col2,b4,[b5,b6])
-    
-    ## Check if all items are in the col3 list, regardless of order
-    case.assertCountEqual(allBoxes,col3.sources)
-
-
-def test_AddSource_mix():
-    # Check if addSource method accepts mixed arguments
-    # source objects, list, collection
-    case = unittest.TestCase()
-    from magpylib import source
-    mag=(2,3,5)
-    dim=(2,2,2)
-
-    ## Define boxes in different position of memory
-    b0 = source.magnet.Box(mag,dim)
-    b1 = source.magnet.Box(mag,dim)
-    b2 = source.magnet.Box(mag,dim)
-    b3 = source.magnet.Box(mag,dim)
-    b4 = source.magnet.Box(mag,dim)
-    b5 = source.magnet.Box(mag,dim)
-    b6 = source.magnet.Box(mag,dim)
-    allBoxes = [b0,b1,b2,b3,b4,b5,b6]
-    col1 = Collection()
-    col1.addSources(b0,b1)
-    col2 = Collection()
-    col2.addSources([b2,b3])
-    col3 = Collection()
-    col3.addSources(col1,col2,b4,[b5,b6])
-
-    ## Check if all items are in the col3 list, regardless of order
-    case.assertCountEqual(allBoxes,col3.sources)
-
-
-def test_GetB():
-    # Check if getB is being called correctly,
-    # getB in collection superimposes (adds) all the fields
-    # generated by the objects in the collection.
-    from magpylib._lib.classes.magnets import Box
-
-    #Input
-    mockList = (    array([0.12488298, 0.10927261, 0.07805186]),
-                    array([0.12488298, 0.10927261, 0.07805186]))
-    mockResult = sum(mockList)
-
-    mag=(2,3,5)
-    dim=(2,2,2)
-    pos=[2,2,2]
-
-    #Run   
-    b = Box(mag,dim)
-    b2 = Box(mag,dim)
-    c = Collection(b,b2)
-    result = c.getB(pos) 
-
-    rounding = 4
-    for j in range(3):
-        assert round(result[j],rounding) == round(mockResult[j],rounding)
-
-
-def test_AddList():
-    # Check if adding a list of generics to collection 
-    # does not throw an error
-    errMsg = "Failed to place items in collection, got: "
-    from magpylib._lib.classes.magnets import Box
-    def boxFactory():
-        mag=(2,3,5)
-        dim=(2,2,2)
-        return Box(mag,dim)
-    listSize = 5
-    boxList = list(boxFactory() for a in range(0,listSize))
-
-    c = Collection(boxList)
-    lenOfCol = len(c.sources)
-    assert lenOfCol == listSize, errMsg + str(lenOfCol) + "; expected: " + str(listSize)
diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py
new file mode 100644
index 000000000..3f5579a5c
--- /dev/null
+++ b/tests/test_Coumpound_setters.py
@@ -0,0 +1,163 @@
+# pylint: disable=eval-used
+# pylint: disable=unused-import
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R  # noqa: F401
+
+import magpylib as magpy
+from magpylib._src.display.traces_base import make_Prism
+
+# pylint: disable=no-member
+
+magpy.defaults.display.backend = "plotly"
+
+# def create_compound_test_data(path=None):
+#     """creates tests data for compound setters testing"""
+#     setters = [
+#         ("orientation=None", dict(orientation="None")),
+#         ("shorter position path", dict(position="np.array([[50, 0, 100]] * 2)")),
+#         (
+#             "shorter orientation path",
+#             dict(orientation="R.from_rotvec([[90,0,0],[0,90,0]], degrees=True)"),
+#         ),
+#         (
+#             "longer position path",
+#             dict(position="np.array(np.linspace((280.,0.,0), (280.,0.,300), 8))"),
+#         ),
+#         (
+#             "longer orientation path",
+#             dict(
+#                 orientation="R.from_rotvec([[0,90*i,0] for i in range(6)], degrees=True)"
+#             ),
+#         ),
+#     ]
+#     data = {"test_names": [], "setters_inputs": [], "pos_orient_as_matrix_expected": []}
+#     for setter in setters:
+#         tname, kwargs = setter
+#         coll = create_compound_set(**kwargs)
+#         pos_orient = get_pos_orient_from_collection(coll)
+#         data["test_names"].append(tname)
+#         data["setters_inputs"].append(kwargs)
+#         data["pos_orient_as_matrix_expected"].append(pos_orient)
+#     if path is None:
+#         return data
+#     np.save(path, data)
+
+
+# def display_compound_test_data(path):
+#     """display compound test data from file"""
+#     data = np.load(path, allow_pickle=True).item()
+#     for kwargs in data["setters_inputs"]:
+#         create_compound_set(show=True, **kwargs)
+
+
+def make_wheel(Ncubes=6, height=10, diameter=36, path_len=5, label=None):
+    """creates a basic Collection Compound object with a rotary arrangement of cuboid magnets"""
+
+    def cs_lambda():
+        return magpy.magnet.Cuboid(
+            polarization=(1, 0, 0),
+            dimension=[height] * 3,
+            position=(diameter / 2, 0, 0),
+        )
+
+    s0 = cs_lambda().rotate_from_angax(
+        np.linspace(0.0, 360.0, Ncubes, endpoint=False), "z", anchor=(0, 0, 0), start=0
+    )
+    c = magpy.Collection()
+    for ind in range(Ncubes):
+        s = cs_lambda()
+        s.position = s0.position[ind]
+        s.orientation = s0.orientation[ind]
+        c.add(s)
+    c.rotate_from_angax(90, "x")
+    c.rotate_from_angax(
+        np.linspace(90, 360, path_len), axis="z", start=0, anchor=(80, 0, 0)
+    )
+    c.move(np.linspace((0, 0, 0), (0, 0, 200), path_len), start=0)
+    c.style.label = label
+
+    trace = make_Prism(
+        "plotly",
+        base=Ncubes,
+        diameter=diameter + height * 2,
+        height=height * 0.5,
+        opacity=0.5,
+        color="blue",
+    )
+
+    c.style.model3d.data = [trace]  # pylint: disable=no-member
+    return c
+
+
+def create_compound_set(**kwargs):
+    """creates a styled Collection Compound object with a rotary arrangement of cuboid magnets.
+    A copy is created to show the difference when applying position and/or orientation setters over
+    kwargs."""
+    c1 = make_wheel(label="Magnetic Wheel after")
+    c1.set_children_styles(
+        path_show=False,
+        magnetization_color_north="magenta",
+        magnetization_color_south="cyan",
+    )
+    c2 = make_wheel(label="Magnetic Wheel before")
+    c2.style.model3d.data[0].kwargs["color"] = "red"
+    c2.style.model3d.data[0].kwargs["opacity"] = 0.1
+    c2.set_children_styles(path_show=False, opacity=0.1)
+    for k, v in kwargs.items():
+        setattr(c1, k, eval(v))
+    # if show:
+    #     fig = go.Figure()
+    #     magpy.show(c2, c1, style_path_frames=1, canvas=fig)
+    #     fig.layout.title = ", ".join(f"c1.{k} = {v}" for k, v in kwargs.items())
+    #     fig.show()
+    return c1
+
+
+def get_pos_orient_from_collection(coll):
+    """returns a list of (position, orientation.as_matrix()) tuple of a collection and of its
+    children"""
+    pos_orient = []
+    for obj in [coll, *coll.children]:
+        pos_orient.append((obj.position, obj.orientation.as_matrix()))
+    return pos_orient
+
+
+folder = "tests/testdata"
+file = Path(folder) / "testdata_compound_setter_cases.npy"
+# create_compound_test_data(file)
+
+COMPOUND_DATA = np.load(file, allow_pickle=True).item()
+
+
+@pytest.mark.parametrize(
+    ("setters_inputs", "pos_orient_as_matrix_expected"),
+    list(
+        zip(
+            COMPOUND_DATA["setters_inputs"],
+            COMPOUND_DATA["pos_orient_as_matrix_expected"],
+            strict=False,
+        )
+    ),
+    ids=COMPOUND_DATA["test_names"],
+)
+def test_compound_setters(setters_inputs, pos_orient_as_matrix_expected):
+    """testing of compound object setters and the effects on its children."""
+    c1 = create_compound_set(**setters_inputs)
+    pos_orient = get_pos_orient_from_collection(c1)
+    for ind, (po, po_exp) in enumerate(
+        zip(pos_orient, pos_orient_as_matrix_expected, strict=False)
+    ):
+        obj_str = "child"
+        if ind == 0:  # first ind is (position, orientation.as_matrix()) of collection
+            obj_str = "Collection"
+        pos, orient = po
+        pos_exp, orient_exp = po_exp
+        err_msg = f"{obj_str} position matching failed"
+        np.testing.assert_almost_equal(pos, pos_exp, err_msg=err_msg)
+        err_msg = f"{obj_str}{ind if ind != 0 else ''} orientation matching failed"
+        np.testing.assert_almost_equal(orient, orient_exp, err_msg=err_msg)
diff --git a/tests/test_CustomSource.py b/tests/test_CustomSource.py
new file mode 100644
index 000000000..3b1508b28
--- /dev/null
+++ b/tests/test_CustomSource.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+
+# pylint: disable=assignment-from-no-return
+# pylint: disable=unused-argument
+
+
+def constant_field(field, observers=(0, 0, 0)):  # noqa: ARG001
+    """constant field"""
+    position = np.array(observers)
+    length = 1 if position.ndim == 1 else len(position)
+    return np.array([[1, 2, 3]] * length)
+
+
+def test_CustomSource_basicB():
+    """Basic custom source class test"""
+    external_field = magpy.misc.CustomSource(field_func=constant_field)
+
+    B = external_field.getB((1, 2, 3))
+    Btest = np.array((1, 2, 3))
+    np.testing.assert_allclose(B, Btest)
+
+    B = external_field.getB([[1, 2, 3], [4, 5, 6]])
+    Btest = np.array([[1, 2, 3]] * 2)
+    np.testing.assert_allclose(B, Btest)
+
+    external_field.rotate_from_angax(45, "z")
+    B = external_field.getB([[1, 2, 3], [4, 5, 6]])
+    Btest = np.array([[-0.70710678, 2.12132034, 3.0]] * 2)
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_CustomSource_basicH():
+    """Basic custom source class test"""
+    external_field = magpy.misc.CustomSource(field_func=constant_field)
+
+    H = external_field.getH((1, 2, 3))
+    Htest = np.array((1, 2, 3))
+    np.testing.assert_allclose(H, Htest)
+
+    H = external_field.getH([[1, 2, 3], [4, 5, 6]])
+    Htest = np.array([[1, 2, 3]] * 2)
+    np.testing.assert_allclose(H, Htest)
+
+    external_field.rotate_from_angax(45, "z")
+    H = external_field.getH([[1, 2, 3], [4, 5, 6]])
+    Htest = np.array([[-0.70710678, 2.12132034, 3.0]] * 2)
+    np.testing.assert_allclose(H, Htest)
+
+
+def test_CustomSource_None():
+    "Set source field_func to None"
+    # pylint: disable=protected-access
+    external_field = magpy.misc.CustomSource(field_func=constant_field)
+    external_field.field_func = None
+    external_field._editable_field_func = False
+    with pytest.raises(AttributeError):
+        external_field.field_func = constant_field
+
+
+def test_repr():
+    """test __repr__"""
+    dip = magpy.misc.CustomSource()
+    assert repr(dip)[:12] == "CustomSource", "Custom_Source repr failed"
diff --git a/tests/test_Math.py b/tests/test_Math.py
deleted file mode 100644
index 81084cc0e..000000000
--- a/tests/test_Math.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from magpylib._lib.mathLib import getPhi,fastSum3D, fastNorm3D, arccosSTABLE, fastCross3D
-from magpylib._lib.mathLib import Qmult, Qnorm2, Qunit, Qconj, getRotQuat, angleAxisRotation_priv
-from magpylib._lib.mathLib import elliptic, ellipticK, ellipticE, ellipticPi
-from numpy import pi,array
-from magpylib._lib.mathLib import randomAxis, axisFromAngles, anglesFromAxis, angleAxisRotation
-import numpy
-
-# -------------------------------------------------------------------------------
-def test_randomAxis():
-    result = randomAxis()
-    assert len(result)==3, "bad randomAxis"
-    assert all(type(axis)==numpy.float64 for axis in result), "bad randomAxis"
-    assert all(abs(axis)<=1 for axis in result), "bad randomAxis"
-
-# -------------------------------------------------------------------------------
-def test_angleAxisRotation():
-    sol = [-0.26138058, 0.59373138, 3.28125372]
-    result = angleAxisRotation([1,2,3],234.5,(0,0.2,1),anchor=[0,1,0])
-    for r,s in zip(result,sol):
-        assert round(r,4) == round(s,4), "bad angleAxisRotation"
-
-# -------------------------------------------------------------------------------
-def test_anglesFromAxis():
-    sol = [90.,11.30993247]
-    result = anglesFromAxis([0,.2,1])
-    for r,s in zip(result,sol):
-        assert round(r,4)==round(s,4), "bad anglesFromAxis"
-
-# -------------------------------------------------------------------------------
-def test_axisFromAngles():
-    sol = [ 5.30287619e-17,  8.66025404e-01, -5.00000000e-01]
-    result = axisFromAngles([90,120])
-    for r,s in zip(result,sol):
-        assert round(r,4)==round(s,4), "bad axisFromAngles"
-
-
-# -------------------------------------------------------------------------------
-def test_algebraic():
-    assert round(getPhi(1,2),4) ==1.1071, "bad getPhi result at (1,2)"
-    assert round(getPhi(1,0),4) ==0.0, "bad getPhi result at (1,0)"
-    assert round(getPhi(-1,0),4)==3.1416, "bad getPhi result at (-1,0)"
-    assert round(getPhi(0,0),4) ==0.0, "bad getPhi result at (0,0)"
-
-    assert round(arccosSTABLE(2),4) == 0.0 , "bad arccosStable at (2)"
-    assert round(arccosSTABLE(-2),4) == 3.1416, "bad arccosStable at (-2)"
-
-    assert all(fastCross3D([1,2,3],[3,2,1]) == array([-4,8,-4])), "bad fastCross3D"
-
-    assert round(fastSum3D([2.3,5.6,2.0]),2)==9.90, "bad fastSum3D"
-
-    assert round(fastNorm3D([58.2,25,25]),4)==68.0973, "bad fastNorm3D"
-
-
-# -------------------------------------------------------------------------------
-def test_Quaternion():
-    Qmult([1,2,3,4],[4,3,2,1]) == [-12,6,24,12]
-    Qconj([1,2,3,4]) == [1,-2,-3,-4]
-    Qnorm2([1,2,3,4]) == 30
-    
-    Q = Qunit([1,2,3,4])
-    sol = [0.1826,0.3651,0.5477,0.7303]
-    for q,s in zip(Q,sol):
-        assert round(q,4)==s, "bad Qunit"
-
-    Q = getRotQuat(33,[1,2,3])
-    sol = [0.9588,0.0759,0.1518,0.2277]
-    for q,s in zip(Q,sol):
-        assert round(q,4)==s, "bad getRotQuat"
-
-
-    V = angleAxisRotation_priv(33,[1,2,3],[4,5,6])
-    sol = [3.2868,5.8042,5.7016]
-    for v,s in zip(V,sol):
-        assert round(v,4)==s, "bad getRotQuat"
-
-
-# -------------------------------------------------------------------------------
-def test_elliptic():
-    assert round(elliptic(.1,.2,.3,.4),4) == 4.7173, "bad elliptic"
-    assert round(ellipticK(.1),4) == 1.6124, "bad ellipticK"
-    assert round(ellipticE(.1),4) == 1.5308, "bad ellipticE"
-    assert round(ellipticPi(.1,.2),4) == 1.752, "bad ellipticPi"
-
-
-
diff --git a/tests/test_MathVector.py b/tests/test_MathVector.py
deleted file mode 100644
index d147183fd..000000000
--- a/tests/test_MathVector.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from magpylib._lib.mathLib_vector import QmultV, QconjV, getRotQuatV, QrotationV, getAngAxV, angleAxisRotationV_priv
-from magpylib._lib.mathLib_vector import axisFromAnglesV, anglesFromAxisV, angleAxisRotationV, ellipticV
-from numpy import array, amax
-from magpylib._lib.mathLib import getRotQuat, axisFromAngles, anglesFromAxis, angleAxisRotation, elliptic
-from magpylib.math import randomAxisV
-import numpy as np
-from magpylib._lib.mathLib import ellipticK, ellipticE, ellipticPi
-from magpylib._lib.mathLib_vector import ellipticKV, ellipticEV, ellipticPiV
-
-
-def test_QV():
-
-    x = array([[1,2,3,4],[2,3,4,5]])
-    y = array([[4,3,2,1],[5,4,3,2]])
-    v = array([[1,2,3],[2,3,4]])
-
-    assert (QmultV(x,y) == array([[-12,6,24,12],[-24,16,40,22]])).all(), "bad QmultV"
-    assert (QconjV(x) == array([[1,-2,-3,-4],[2,-3,-4,-5]])).all(), "bad Qconj"
-    assert (QrotationV(x,v) == array([[54,60,78],[140,158,200]])).all(), "bad Qrotation"
-
-
-
-def test_getRotQuatV():
-    ANGS = np.random.rand(5)*360
-    AXES = randomAxisV(5)
-
-    Qs = getRotQuatV(ANGS,AXES)
-    for Q,ang,axe in zip(Qs,ANGS,AXES):
-        assert amax(Q - array(getRotQuat(ang,axe))) < 1e-10, "bad getRotQuatV"
-
-
-
-def test_aaRot_priv():
-    A = np.array([22,133,244])
-    AX = np.array([[.1,.2,.3],[3,4,5],[-7,-8,-9]])
-    V = np.array([[1,2,3],[2,3,4],[4,5,6]])
-
-    sol = np.array([[1.,2.,3.,], 
-        [2.57438857, 2.86042187, 3.76702936],
-        [4.77190313, 4.65730779, 5.70424619]])
-
-    assert amax(abs(angleAxisRotationV_priv(A,AX,V)-sol))<1e-6, "bad angleAxisRotationV_priv"
-
-
-
-def test_randomAxisV():
-    X=randomAxisV(1000)
-    assert X.shape == (1000,3), "bad randomAxis"
-    
-    lX = np.linalg.norm(X,axis=1)
-    assert np.sum(np.abs(lX-1)<1e-10)==1000, "bad randomAxis"
-
-
-
-def test_axisFromAnglesV():
-
-    ANG = np.array([[33,44],[-123,98],[-233,0],[0,0]])
-    AXV = axisFromAnglesV(ANG)
-
-    for axV,ang in zip(AXV,ANG):
-        ax = axisFromAngles(ang)
-        assert amax(abs(ax-axV)) < 1e-10, "bad axisFromAnglesV"
-
-
-
-def test_anglesFromAxis():
-    AX = np.array([[.1,.2,.3],[3,4,5],[-7,-8,-9],[1,0,0],[0,1,0],[0,0,1]])
-    AXV = anglesFromAxisV(AX)
-    for ax,axV in zip(AX,AXV):
-        assert amax(abs(anglesFromAxis(ax)-axV))<1e-10, "bad anglesFromAxis"
-
-
-
-def test_rot_Q_conversion():
-    X = array([[.1,.2,.3,.4],[.2,.3,.4,.5],[-.33,-.55,.1,.23]])
-    ang,ax = getAngAxV(X)
-    Y = getRotQuatV(ang,ax)
-
-    for x,y in zip(X,Y):
-        assert amax(abs(x[0]-y[0]))<1e-10, "bad rot-Q conversion"
-        assert amax(abs(x[1:]/amax(abs(x[1:]))-y[1:]/amax(abs(y[1:]))))<1e-10,"bad rot-Q conversion"
-
-
-
-def test_angleAxisRotationV():
-    POS = np.array([[.1,.2,.3],[0,1,0],[0,0,1]])
-    ANG = np.array([22,233,-123])
-    AXIS = np.array([[3,4,5],[-7,-8,-9],[1,0,0]])
-    ANCHOR = np.array([[.1,3,.3],[5,5,5],[2,-3,1.23]])
-
-    SOL = angleAxisRotationV(POS,ANG,AXIS,ANCHOR)
-    for pos,ang,ax,anch,sol in zip(POS,ANG,AXIS,ANCHOR,SOL):
-        assert np.amax(np.abs(angleAxisRotation(pos,ang,ax,anch)-sol))<1e-10, "bad angleAxisRotationV"
-
-
-
-def test_ellipticV():
-    #random input
-    INP = np.random.rand(1000,4)
-
-    # classical solution looped
-    solC = []
-    for inp in INP:
-        solC += [elliptic(inp[0],inp[1],inp[2],inp[3])]
-    solC = np.array(solC)
-
-    #vector solution
-    solV = ellipticV(INP[:,0],INP[:,1],INP[:,2],INP[:,3])
-
-    assert np.amax(abs(solC-solV)) < 1e-10
-
-
-
-def test_ellipticSpecialCases():
-    X = np.linspace(0,.9999,33)
-    Y = np.linspace(0,.9999,33)[::-1]
-
-    solV = ellipticKV(X)
-    solC = np.array([ellipticK(x) for x in X])
-    assert np.amax(abs(solV-solC)) < 1e-15
-
-    solV = ellipticEV(X)
-    solC = np.array([ellipticE(x) for x in X])
-    assert np.amax(abs(solV-solC)) < 1e-15
-
-    solV = ellipticPiV(X,Y)
-    solC = np.array([ellipticPi(x,y) for x,y in zip(X,Y)])
-    assert np.amax(abs(solV-solC)) < 1e-15
diff --git a/tests/test_Sensor.py b/tests/test_Sensor.py
deleted file mode 100644
index 5ab2a2860..000000000
--- a/tests/test_Sensor.py
+++ /dev/null
@@ -1,83 +0,0 @@
-from magpylib._lib.classes.sensor import Sensor
-from numpy import array, around
-
-
-def test_getB():
-    errMsg = "Unexpected result for Sensor getB"
-    from magpylib import source, Collection
-    b1 = source.magnet.Box([100, 100, 100], [1, 2, 3])
-    b2 = source.magnet.Box([100, 100, 100], [1, 2, 3])
-    sensorPosition = [1, 2, 3]
-    angle = 0
-    axis = [0, 0, 1]
-
-    col = Collection(b1, b2)
-    sensor = Sensor(sensorPosition, angle, axis)
-
-    result = sensor.getB(b1, b2)
-    expected = col.getB(sensorPosition)
-
-    rounding = 4
-    for i in range(0, 3):
-        assert round(result[i], rounding) == round(
-            expected[i], rounding), errMsg
-
-
-def test_getB_col():
-    errMsg = "Unexpected result for Sensor getB"
-    from magpylib import source, Collection
-    sensorPosition = [1, 2, 3]
-    sensorAngle = 0
-    sensorAxis = (0, 0, 1)
-    boxMagnetization = [100, 100, 100]
-    boxDimensions = [1, 2, 3]
-    boxPos = [0, 0, 0]
-    b1 = source.magnet.Box(boxMagnetization, boxDimensions,
-                           boxPos, sensorAngle, sensorAxis)
-    b2 = source.magnet.Box(boxMagnetization, boxDimensions,
-                           boxPos, sensorAngle, sensorAxis)
-
-    col = Collection(b1, b2)
-    sensor = Sensor(sensorPosition, sensorAngle, sensorAxis)
-
-    result = sensor.getB(col)
-    expected = col.getB(sensorPosition)
-
-    rounding = 4
-    for i in range(0, 3):
-        assert round(result[i], rounding) == round(
-            expected[i], rounding), errMsg
-
-
-def test_getB_rotated_XYZ():
-    # Rotate the sensor in Y and X for a Z oriented magnetization vector
-    from magpylib import source
-    errMsg = "Unexpected result for Sensor getB"
-    # Definitions
-    boxMagnetization = [0, 0, 126]
-    boxDimensions = [0.5, 1, 1]
-    boxPos = [1, 1, 1]
-    expected = [array([0., 0., 100.19107165]),
-                array([1.00191072e+02, 6.85197764e-14, 2.13162821e-14]),
-                array([5.68434189e-14, -1.00191072e+02, -1.42108547e-14])]
-    results = []
-
-    # Run
-    s = Sensor(pos=boxPos)
-
-    box = source.magnet.Box(boxMagnetization,
-                            boxDimensions,
-                            pos=boxPos)
-
-    s.rotate(180, [0, 0, 1])  # 180 in Z
-    results.append(s.getB(box))  # Check unchanged
-    s.rotate(90, [0, 1, 0])  # 90 in Y
-    results.append(s.getB(box))  # Check change
-    s.rotate(90, [1, 0, 0])  # 90 in X
-    results.append(s.getB(box))  # Check change
-
-    rounding = 4
-    for j in range(0, len(expected)):
-        for i in range(0, 3):
-            assert around(results[j][i], rounding) == around(
-                expected[j][i], rounding), errMsg
diff --git a/tests/test__missing_optional_modules.py b/tests/test__missing_optional_modules.py
new file mode 100644
index 000000000..9fc4b3290
--- /dev/null
+++ b/tests/test__missing_optional_modules.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import sys
+from unittest.mock import patch
+
+import pytest
+
+import magpylib as magpy
+
+
+def test_show_with_missing_pyvista():
+    """Should raise if pyvista is not installed"""
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    with patch.dict(sys.modules, {"pyvista": None}), pytest.raises(ModuleNotFoundError):
+        src.show(return_fig=True, backend="pyvista")
+
+
+def test_dataframe_output_missing_pandas():
+    """test if pandas is installed when using dataframe output in `getBH`"""
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    with patch.dict(sys.modules, {"pandas": None}), pytest.raises(ModuleNotFoundError):
+        src.getB((0, 0, 0), output="dataframe")
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 000000000..9879cb142
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,17 @@
+# here all core functions should be tested properly - ideally against FEM
+from __future__ import annotations
+
+import numpy as np
+
+from magpylib._src.fields.field_BH_sphere import magnet_sphere_Bfield
+
+
+def test_magnet_sphere_Bfield():
+    "magnet_sphere_Bfield test"
+    B = magnet_sphere_Bfield(
+        observers=np.array([(0, 0, 0)]),
+        diameters=np.array([1]),
+        polarizations=np.array([(0, 0, 1)]),
+    )
+    Btest = np.array([(0, 0, 2 / 3)])
+    np.testing.assert_allclose(B, Btest)
diff --git a/tests/test_core_physics_consistency.py b/tests/test_core_physics_consistency.py
new file mode 100644
index 000000000..e1efeb2c6
--- /dev/null
+++ b/tests/test_core_physics_consistency.py
@@ -0,0 +1,634 @@
+from __future__ import annotations
+
+import itertools
+
+import numpy as np
+from scipy.constants import mu_0 as MU0
+
+from magpylib._src.fields.field_BH_circle import BHJM_circle
+from magpylib._src.fields.field_BH_cuboid import BHJM_magnet_cuboid
+from magpylib._src.fields.field_BH_cylinder import BHJM_magnet_cylinder
+from magpylib._src.fields.field_BH_cylinder_segment import BHJM_cylinder_segment
+from magpylib._src.fields.field_BH_dipole import BHJM_dipole
+from magpylib._src.fields.field_BH_polyline import BHJM_current_polyline
+from magpylib._src.fields.field_BH_sphere import BHJM_magnet_sphere
+from magpylib._src.fields.field_BH_tetrahedron import BHJM_magnet_tetrahedron
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+
+# PHYSICS CONSISTENCY TESTING
+#
+# Magnetic moment of a current loop with current I and surface A:
+#   mom = I * A
+#
+# Magnetic moment of a homogeneous magnet with magnetization mag and volume vol
+#   mom = vol * mag
+#
+# Current replacement picture: A magnet generates a similar field as a current sheet
+#   on its surface with current density j = M = J/MU0. Such a current generates
+#   the same B-field. The H-field generated by is H-M!
+#
+# Geometric approximation testing should give similar results for different
+# implementations when one geometry is constructed from another
+#
+# Scaling invariance of solutions
+#
+# ----------> Circle          # webpage numbers
+# Circle   <> Dipole          # mom = I*A (far field approx)
+# Polyline <> Dipole          # mom = I*A (far field approx)
+# Dipole   <> Sphere          # mom = vol*mag (similar outside of sphere)
+# Dipole   <> all Magnets     # mom = vol*mag (far field approx)
+# Circle   <> Cylinder        # j = I*N/L == J/MU0 current replacement picture
+# Polyline <> Cuboid          # j = I*N/L == J/MU0 current replacement picture
+# Circle   <> Polyline        # geometric approx
+# Cylinder <> CylinderSegment # geometric approx
+# Triangle <> Cuboid          # geometric approx
+# Triangle <> Triangle        # geometric approx
+# Cuboid   <> Tetrahedron     # geometric approx
+
+
+# Circle<>Dipole
+def test_core_phys_moment_of_current_circle():
+    """
+    test dipole vs circular current loop
+    moment = current * surface
+    far field test
+    """
+    obs = np.array([(10, 20, 30), (-10, -20, 30)])
+    dia = np.array([2, 2])
+    curr = np.array([1e3, 1e3])
+    mom = ((dia / 2) ** 2 * np.pi * curr * np.array([(0, 0, 1)] * 2).T).T
+
+    B1 = BHJM_circle(
+        field="B",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )
+    B2 = BHJM_dipole(
+        field="B",
+        observers=obs,
+        moment=mom,
+    )
+    np.testing.assert_allclose(B1, B2, rtol=1e-02)
+
+    H1 = BHJM_circle(
+        field="H",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )
+    H2 = BHJM_dipole(
+        field="H",
+        observers=obs,
+        moment=mom,
+    )
+    np.testing.assert_allclose(H1, H2, rtol=1e-02)
+
+
+# Polyline <> Dipole
+def test_core_phys_moment_of_current_square():
+    """
+    test dipole VS square current loop
+    moment = I x A, far field test
+    """
+    obs1 = np.array([(10, 20, 30)])
+    obs4 = np.array([(10, 20, 30)] * 4)
+    vert = np.array([(1, 1, 0), (1, -1, 0), (-1, -1, 0), (-1, 1, 0), (1, 1, 0)])
+    curr1 = 1e3
+    curr4 = np.array([curr1] * 4)
+    mom = (4 * curr1 * np.array([(0, 0, 1)]).T).T
+
+    B1 = BHJM_current_polyline(
+        field="B",
+        observers=obs4,
+        segment_start=vert[:-1],
+        segment_end=vert[1:],
+        current=curr4,
+    )
+    B1 = np.sum(B1, axis=0)
+    B2 = BHJM_dipole(
+        field="B",
+        observers=obs1,
+        moment=mom,
+    )[0]
+    np.testing.assert_allclose(B1, -B2, rtol=1e-03)
+
+    H1 = BHJM_current_polyline(
+        field="H",
+        observers=obs4,
+        segment_start=vert[:-1],
+        segment_end=vert[1:],
+        current=curr4,
+    )
+    H1 = np.sum(H1, axis=0)
+    H2 = BHJM_dipole(
+        field="H",
+        observers=obs1,
+        moment=mom,
+    )[0]
+    np.testing.assert_allclose(H1, -H2, rtol=1e-03)
+
+
+# Circle <> Polyline
+def test_core_phys_circle_polyline():
+    """approximate circle with polyline"""
+    ts = np.linspace(0, 2 * np.pi, 300)
+    vert = np.array([(np.sin(t), np.cos(t), 0) for t in ts])
+    curr = np.array([1])
+    curr99 = np.array([1] * 299)
+    obs = np.array([(1, 2, 3)])
+    obs99 = np.array([(1, 2, 3)] * 299)
+    dia = np.array([2])
+
+    H1 = BHJM_circle(
+        field="H",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )[0]
+    H2 = BHJM_current_polyline(
+        field="H",
+        observers=obs99,
+        segment_start=vert[:-1],
+        segment_end=vert[1:],
+        current=curr99,
+    )
+    H2 = np.sum(H2, axis=0)
+    np.testing.assert_allclose(H1, -H2, rtol=1e-4)
+
+    B1 = BHJM_circle(
+        field="B",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )[0]
+    B2 = BHJM_current_polyline(
+        field="B",
+        observers=obs99,
+        segment_start=vert[:-1],
+        segment_end=vert[1:],
+        current=curr99,
+    )
+    B2 = np.sum(B2, axis=0)
+    np.testing.assert_allclose(B1, -B2, rtol=1e-4)
+
+
+# Dipole <> Sphere
+def test_core_physics_dipole_sphere():
+    """
+    dipole and sphere field must be similar outside of sphere
+    moment = magnetization * volume
+    near field tests
+    """
+    obs = np.array([(1, 2, 3), (-2, -2, -2), (3, 5, -4), (5, 4, 0.1)])
+    dia = np.array([2, 3, 0.1, 3.3])
+    pol = np.array([(1, 2, 3), (0, 0, 1), (-1, -2, 0), (1, -1, 0.1)])
+    mom = np.array(
+        [4 * (d / 2) ** 3 * np.pi / 3 * p / MU0 for d, p in zip(dia, pol, strict=False)]
+    )
+
+    B1 = BHJM_magnet_sphere(
+        field="B",
+        observers=obs,
+        diameter=dia,
+        polarization=pol,
+    )
+    B2 = BHJM_dipole(
+        field="B",
+        observers=obs,
+        moment=mom,
+    )
+    np.testing.assert_allclose(B1, B2, rtol=0, atol=1e-16)
+
+    H1 = BHJM_magnet_sphere(
+        field="H",
+        observers=obs,
+        diameter=dia,
+        polarization=pol,
+    )
+    H2 = BHJM_dipole(
+        field="H",
+        observers=obs,
+        moment=mom,
+    )
+    np.testing.assert_allclose(H1, H2, rtol=0, atol=1e-10)
+
+
+# -> Circle, Cylinder
+def test_core_physics_long_solenoid():
+    """
+    Test if field from solenoid converges to long-solenoid field in the center
+        Bz_long = MU0*I*N/L
+        Hz_long = I*N/L
+    I = current, N=windings, L=length, holds true if L >> radius R
+
+    This can also be tested with magnets using the current replacement picture
+        where Jz = MU0 * I * N / L, and holds for B and for H-M.
+    """
+
+    I = 134  # noqa: E741
+    N = 5000
+    R = 1.543
+    L = 1234
+
+    for field in ["B", "H"]:
+        BHz_long = N * I / L
+        if field == "B":
+            BHz_long *= MU0
+
+        # SOLENOID TEST constructed from circle fields
+        BH = BHJM_circle(
+            field=field,
+            observers=np.linspace((0, 0, -L / 2), (0, 0, L / 2), N),
+            diameter=np.array([2 * R] * N),
+            current=np.array([I] * N),
+        )
+        BH_sol = np.sum(BH, axis=0)[2]
+
+        np.testing.assert_allclose(BHz_long, BH_sol, rtol=1e-3)
+
+        # MAGNET TEST using the current replacement picture
+        Mz = I * N / L
+        Jz = Mz * MU0
+        pol = np.array([(0, 0, Jz)])
+        obs = np.array([(0, 0, 0)])
+
+        # cylinder
+        BHz_cyl = BHJM_magnet_cylinder(
+            field=field,
+            observers=obs,
+            dimension=np.array([(2 * R, L)]),
+            polarization=pol,
+        )[0, 2]
+        if field == "H":
+            BHz_cyl += Mz
+        np.testing.assert_allclose(BHz_long, BHz_cyl, rtol=1e-5)
+
+        # cuboid
+        BHz_cub = BHJM_magnet_cuboid(
+            field=field,
+            observers=obs,
+            dimension=np.array([(2 * R, 2 * R, L)]),
+            polarization=pol,
+        )[0, 2]
+        if field == "H":
+            BHz_cub += Mz
+        np.testing.assert_allclose(BHz_long, BHz_cub, rtol=1e-5)
+
+
+# Circle<>Cylinder
+def test_core_physics_current_replacement():
+    """
+    test if the Cylinder field is given by a replacement current sheet
+    that carries a current density of j=magnetization
+    It follows:
+        j = I*N/L == J/MU0
+          -> I = J/MU0/N*L
+    near-field test
+    """
+    L = 0.5
+    R = 0.987
+    obs = np.array([(1.5, -2, -1.123)])
+
+    Jz = 1
+    Hz_cyl = BHJM_magnet_cylinder(
+        field="H",
+        observers=obs,
+        dimension=np.array([(2 * R, L)]),
+        polarization=np.array([(0, 0, Jz)]),
+    )[0, 2]
+
+    N = 1000  # current discretization
+    H = BHJM_circle(
+        field="H",
+        observers=np.linspace((0, 0, -L / 2), (0, 0, L / 2), N) + obs,
+        diameter=np.array([2 * R] * N),
+        current=np.array([Jz / MU0 / N * L] * N),
+    )
+    Hz_curr = np.sum(H, axis=0)[2]
+
+    np.testing.assert_allclose(Hz_curr, Hz_cyl, rtol=1e-4)
+
+
+# Cylinder<>CylinderSegment
+def test_core_physics_geometry_cylinder_from_segments():
+    """test if multiple Cylinder segments create the same field as fully cylinder"""
+    r = 1.23
+    h = 3
+    obs = np.array([(1, 2, 3), (0.23, 0.132, 0.123)])
+    pol = np.array([(2, 0.123, 3), (-0.23, -1, 0.434)])
+
+    B_cyl = BHJM_magnet_cylinder(
+        field="B",
+        observers=obs,
+        dimension=np.array([(2 * r, h)] * 2),
+        polarization=pol,
+    )
+    sections = np.array([-12, 65, 123, 180, 245, 348])
+
+    Bseg = np.zeros((2, 3))
+    for phi1, phi2 in itertools.pairwise(sections):
+        B_part = BHJM_cylinder_segment(
+            field="B",
+            observers=obs,
+            dimension=np.array([(0, r, h, phi1, phi2)] * 2),
+            polarization=pol,
+        )
+        Bseg[0] += B_part[0]
+        Bseg[1] += B_part[1]
+    np.testing.assert_allclose(B_cyl, Bseg)
+
+
+# Dipole<>Cuboid, Cylinder, CylinderSegment, Tetrahedron
+def test_core_physics_dipole_approximation_magnet_far_field():
+    """test if all magnets satisfy the dipole approximation"""
+    obs = np.array([(100, 200, 300), (-200, -200, -200)])
+
+    mom = np.array([(1e6, 2e6, 3e6)] * 2)
+    Bdip = BHJM_dipole(
+        field="H",
+        observers=obs,
+        moment=mom,
+    )
+
+    dim = np.array([(2, 2, 2)] * 2)
+    vol = 8
+    pol = mom / vol * MU0
+    Bcub = BHJM_magnet_cuboid(
+        field="H",
+        observers=obs,
+        dimension=dim,
+        polarization=pol,
+    )
+    # np.testing.assert_allclose(Bdip, Bcub)
+    err = np.linalg.norm(Bdip - Bcub) / np.linalg.norm(Bdip)
+    assert err < 1e-6
+
+    dim = np.array([(0.5, 0.5)] * 2)
+    vol = 0.25**2 * np.pi * 0.5
+    pol = mom / vol * MU0
+    Bcyl = BHJM_magnet_cylinder(
+        field="H",
+        observers=obs,
+        dimension=dim,
+        polarization=pol,
+    )
+    # np.testing.assert_allclose(Bdip, Bcyl, rtol=1e-6)
+    err = np.linalg.norm(Bdip - Bcyl) / np.linalg.norm(Bdip)
+    assert err < 1e-6
+
+    vert = np.array([[(0, 0, 0), (0, 0, 0.1), (0.1, 0, 0), (0, 0.1, 0)]] * 2)
+    vol = 1 / 6 * 1e-3
+    pol = mom / vol * MU0
+    Btetra = BHJM_magnet_tetrahedron(
+        field="H",
+        observers=obs,
+        vertices=vert,
+        polarization=pol,
+    )
+    # np.testing.assert_allclose(Bdip, Btetra, rtol=1e-3)
+    err = np.linalg.norm(Bdip - Btetra) / np.linalg.norm(Bdip)
+    assert err < 1e-3
+
+    dim = np.array([(0.1, 0.2, 0.1, -25, 25)] * 2)
+    vol = 3 * np.pi * (50 / 360) * 1e-3
+    pol = mom / vol * MU0
+    Bcys = BHJM_cylinder_segment(
+        field="H",
+        observers=obs + np.array((0.15, 0, 0)),
+        dimension=dim,
+        polarization=pol,
+    )
+    # np.testing.assert_allclose(Bdip, Bcys, rtol=1e-4)
+    err = np.linalg.norm(Bdip - Bcys) / np.linalg.norm(Bdip)
+    assert err < 1e-4
+
+
+# --> Circle
+def test_core_physics_circle_VS_webpage_numbers():
+    """
+    Compare Circle on-axis field vs e-magnetica & hyperphysics
+    """
+    dia = np.array([2] * 4)
+    curr = np.array([1e3] * 4)  # A
+    zs = [0, 1, 2, 3]
+    obs = np.array([(0, 0, z) for z in zs])
+
+    # values from e-magnetica
+    Hz = [500, 176.8, 44.72, 15.81]
+    Htest = [(0, 0, hz) for hz in Hz]
+
+    H = BHJM_circle(
+        field="H",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )
+    np.testing.assert_allclose(H, Htest, rtol=1e-3)
+
+    # values from hyperphysics
+    Bz = [
+        0.6283185307179586e-3,
+        2.2214414690791835e-4,
+        5.619851784832581e-5,
+        1.9869176531592205e-5,
+    ]
+    Btest = [(0, 0, bz) for bz in Bz]
+
+    B = BHJM_circle(
+        field="B",
+        observers=obs,
+        diameter=dia,
+        current=curr,
+    )
+    np.testing.assert_allclose(B, Btest, rtol=1e-7)
+
+
+# Cuboid <> Polyline
+def test_core_physics_cube_current_replacement():
+    """compare cuboid field with current replacement"""
+    obs = np.array([(2, 2, 3.13), (-2.123, -4, 2)])
+    h = 1
+    Jz = 1.23
+    dim = np.array([(2, 2, h)] * 2)
+    pol = np.array([(0, 0, Jz)] * 2)
+    Hcub = BHJM_magnet_cuboid(
+        field="H",
+        observers=obs,
+        dimension=dim,
+        polarization=pol,
+    )
+
+    # construct from polylines
+    n = 1000
+    vert = np.array([(1, 1, 0), (1, -1, 0), (-1, -1, 0), (-1, 1, 0), (1, 1, 0)])
+    curr = h / n * Jz / MU0
+    hs = np.linspace(-h / 2, h / 2, n)
+    hpos = np.array([(0, 0, h) for h in hs])
+
+    obs1 = np.array([obs[0] + hp for hp in hpos] * 4)
+    obs2 = np.array([obs[1] + hp for hp in hpos] * 4)
+
+    start = np.repeat(vert[:-1], n, axis=0)
+    end = np.repeat(vert[1:], n, axis=0)
+
+    Hcurr = np.zeros((2, 3))
+    for i, obss in enumerate([obs1, obs2]):
+        h = BHJM_current_polyline(
+            field="H",
+            observers=obss,
+            segment_start=start,
+            segment_end=end,
+            current=np.array([curr] * 4 * n),
+        )
+        Hcurr[i] = np.sum(h, axis=0)
+
+    np.testing.assert_allclose(Hcub, -Hcurr, rtol=1e-4)
+
+
+def test_core_physics_triangle_cube_geometry():
+    """test core triangle VS cube"""
+    obs = np.array([(3, 4, 5)] * 4)
+    mag = np.array([(0, 0, 333)] * 4)
+    fac = np.array(
+        [
+            [(-1, -1, 1), (1, -1, 1), (-1, 1, 1)],  # top1
+            [(1, -1, -1), (-1, -1, -1), (-1, 1, -1)],  # bott1
+            [(1, -1, 1), (1, 1, 1), (-1, 1, 1)],  # top2
+            [(1, 1, -1), (1, -1, -1), (-1, 1, -1)],  # bott2
+        ]
+    )
+    b = BHJM_triangle(
+        field="B",
+        observers=obs,
+        vertices=fac,
+        polarization=mag,
+    )
+    b = np.sum(b, axis=0)
+
+    obs = np.array([(3, 4, 5)])
+    mag = np.array([(0, 0, 333)])
+    dim = np.array([(2, 2, 2)])
+    bb = BHJM_magnet_cuboid(
+        field="B",
+        observers=obs,
+        dimension=dim,
+        polarization=mag,
+    )[0]
+
+    np.testing.assert_allclose(b, bb)
+
+
+def test_core_physics_triangle_VS_itself():
+    """test core single triangle vs same surface split up into 4 triangular faces"""
+    obs = np.array([(3, 4, 5)])
+    mag = np.array([(111, 222, 333)])
+    fac = np.array(
+        [
+            [(0, 0, 0), (10, 0, 0), (0, 10, 0)],
+        ]
+    )
+    b = BHJM_triangle(
+        field="B",
+        observers=obs,
+        polarization=mag,
+        vertices=fac,
+    )
+    b = np.sum(b, axis=0)
+
+    obs = np.array([(3, 4, 5)] * 4)
+    mag = np.array([(111, 222, 333)] * 4)
+    fac = np.array(
+        [
+            [(0, 0, 0), (3, 0, 0), (0, 10, 0)],
+            [(3, 0, 0), (5, 0, 0), (0, 10, 0)],
+            [(5, 0, 0), (6, 0, 0), (0, 10, 0)],
+            [(6, 0, 0), (10, 0, 0), (0, 10, 0)],
+        ]
+    )
+    bb = BHJM_triangle(
+        field="B",
+        observers=obs,
+        polarization=mag,
+        vertices=fac,
+    )
+    bb = np.sum(bb, axis=0)
+
+    np.testing.assert_allclose(b, bb)
+
+
+def test_core_physics_Tetrahedron_VS_Cuboid():
+    """test core tetrahedron vs cube"""
+    ver = np.array(
+        [
+            [(1, 1, -1), (1, 1, 1), (-1, 1, 1), (1, -1, 1)],
+            [(-1, -1, 1), (-1, 1, 1), (1, -1, 1), (1, -1, -1)],
+            [(-1, -1, -1), (-1, -1, 1), (-1, 1, -1), (1, -1, -1)],
+            [(-1, 1, -1), (1, -1, -1), (-1, -1, 1), (-1, 1, 1)],
+            [(1, -1, -1), (1, 1, -1), (1, -1, 1), (-1, 1, 1)],
+            [(-1, 1, -1), (-1, 1, 1), (1, 1, -1), (1, -1, -1)],
+        ]
+    )
+
+    mags = [
+        [1.03595366, 0.42840487, 0.10797529],
+        [0.33513152, 1.61629547, 0.15959791],
+        [0.29904441, 1.32185041, 1.81218046],
+        [0.82665456, 1.86827489, 1.67338911],
+        [0.97619806, 1.52323106, 1.63628455],
+        [1.70290645, 1.49610608, 0.13878711],
+        [1.49886747, 1.55633919, 1.41351862],
+        [0.9959534, 0.62059942, 1.28616663],
+        [0.60114354, 0.96120344, 0.32009221],
+        [0.83133901, 0.7925518, 0.64574592],
+    ]
+
+    obss = [
+        [0.82811352, 1.77818627, 0.19819379],
+        [0.84147235, 1.10200857, 1.51687527],
+        [0.30751474, 0.89773196, 0.56468564],
+        [1.87437889, 1.55908581, 1.10579983],
+        [0.64810548, 1.38123846, 1.90576802],
+        [0.48981034, 0.09376294, 0.53717129],
+        [1.42826412, 0.30246674, 0.57649909],
+        [1.58376758, 1.70420478, 0.22894022],
+        [0.26791832, 0.36839769, 0.67934335],
+        [1.15140149, 0.10549875, 0.98304184],
+    ]
+
+    for mag in mags:
+        for obs in obss:
+            obs6 = np.tile(obs, (6, 1))
+            mag6 = np.tile(mag, (6, 1))
+            b = BHJM_magnet_tetrahedron(
+                field="B",
+                observers=obs6,
+                polarization=mag6,
+                vertices=ver,
+            )
+            h = BHJM_magnet_tetrahedron(
+                field="H",
+                observers=obs6,
+                polarization=mag6,
+                vertices=ver,
+            )
+            b = np.sum(b, axis=0)
+            h = np.sum(h, axis=0)
+
+            obs1 = np.reshape(obs, (1, 3))
+            mag1 = np.reshape(mag, (1, 3))
+            dim = np.array([(2, 2, 2)])
+            bb = BHJM_magnet_cuboid(
+                field="B",
+                observers=obs1,
+                polarization=mag1,
+                dimension=dim,
+            )[0]
+            hh = BHJM_magnet_cuboid(
+                field="H",
+                observers=obs1,
+                polarization=mag1,
+                dimension=dim,
+            )[0]
+            np.testing.assert_allclose(b, bb)
+            np.testing.assert_allclose(h, hh)
diff --git a/tests/test_default_utils.py b/tests/test_default_utils.py
new file mode 100644
index 000000000..f2ee4a2dc
--- /dev/null
+++ b/tests/test_default_utils.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+from copy import deepcopy
+
+import pytest
+
+from magpylib._src.defaults.defaults_utility import (
+    COLORS_SHORT_TO_LONG,
+    MagicProperties,
+    color_validator,
+    get_defaults_dict,
+    linearize_dict,
+    magic_to_dict,
+    update_nested_dict,
+)
+
+
+def test_update_nested_dict():
+    """test all argument combinations of `update_nested_dicts`"""
+    # `d` gets updated, that's why we deepcopy it
+    d = {"a": 1, "b": {"c": 2, "d": None}, "f": None, "g": {"c": None, "d": 2}, "h": 1}
+    u = {"a": 2, "b": 3, "e": 5, "g": {"c": 7, "d": 5}, "h": {"i": 3}}
+    res = update_nested_dict(
+        deepcopy(d), u, same_keys_only=False, replace_None_only=False
+    )
+    assert res == {
+        "a": 2,
+        "b": 3,
+        "e": 5,
+        "f": None,
+        "g": {"c": 7, "d": 5},
+        "h": {"i": 3},
+    }, "failed updating nested dict"
+    res = update_nested_dict(
+        deepcopy(d), u, same_keys_only=True, replace_None_only=False
+    )
+    assert res == {
+        "a": 2,
+        "b": 3,
+        "f": None,
+        "g": {"c": 7, "d": 5},
+        "h": {"i": 3},
+    }, "failed updating nested dict"
+    res = update_nested_dict(
+        deepcopy(d), u, same_keys_only=True, replace_None_only=True
+    )
+    assert res == {
+        "a": 1,
+        "b": {"c": 2, "d": None},
+        "f": None,
+        "g": {"c": 7, "d": 2},
+        "h": 1,
+    }, "failed updating nested dict"
+    res = update_nested_dict(
+        deepcopy(d), u, same_keys_only=False, replace_None_only=True
+    )
+    assert res == {
+        "a": 1,
+        "b": {"c": 2, "d": None},
+        "f": None,
+        "g": {"c": 7, "d": 2},
+        "e": 5,
+        "h": 1,
+    }, "failed updating nested dict"
+
+
+def test_magic_to_dict():
+    """test all argument combinations of `magic_to_dict`"""
+    d = {"a_b": 1, "c_d_e": 2, "a": 3, "c_d": {"e": 6}}
+    res = magic_to_dict(d, separator="_")
+    assert res == {"a": 3, "c": {"d": {"e": 6}}}
+    d = {"a.b": 1, "c": 2, "a": 3, "c.d": {"e": 6}}
+    res = magic_to_dict(d, separator=".")
+    assert res == {"a": 3, "c": {"d": {"e": 6}}}
+    with pytest.raises(AssertionError):
+        magic_to_dict(0, separator=".")
+    with pytest.raises(AssertionError):
+        magic_to_dict(d, separator=0)
+
+
+def test_linearize_dict():
+    """test all argument combinations of `magic_to_dict`"""
+    mydict = {
+        "line": {"width": 1, "style": "solid", "color": None},
+        "marker": {"size": 1, "symbol": "o", "color": None},
+    }
+    res = linearize_dict(mydict, separator=".")
+    assert res == {
+        "line.width": 1,
+        "line.style": "solid",
+        "line.color": None,
+        "marker.size": 1,
+        "marker.symbol": "o",
+        "marker.color": None,
+    }, "linearization of dict failed"
+    with pytest.raises(AssertionError):
+        magic_to_dict(0, separator=".")
+    with pytest.raises(AssertionError):
+        magic_to_dict(mydict, separator=0)
+
+
+@pytest.mark.parametrize(
+    ("color", "allow_None", "color_expected"),
+    [
+        (None, True, None),
+        ("blue", True, "blue"),
+        ("r", True, "red"),
+        (0, True, "#000000"),
+        (0.5, True, "#7f7f7f"),
+        ("0.5", True, "#7f7f7f"),
+        ((127, 127, 127), True, "#7f7f7f"),
+        ("rgb(127, 127, 127)", True, "#7f7f7f"),
+        ((0, 0, 0, 0), False, "#000000"),
+        ((0.1, 0.2, 0.3), False, "#19334c"),
+    ]
+    + [(shortC, True, longC) for shortC, longC in COLORS_SHORT_TO_LONG.items()],
+)
+def test_good_colors(color, allow_None, color_expected):
+    """test color validator based on matploblib validation"""
+
+    assert color_validator(color, allow_None=allow_None) == color_expected
+
+
+@pytest.mark.parametrize(
+    ("color", "allow_None", "expected_exception"),
+    [
+        (None, False, ValueError),
+        (-1, False, ValueError),
+        ((-1, 0, 0), False, ValueError),
+        ((1, 2), False, ValueError),
+        ((0, 0, 260), False, ValueError),
+        ((0, "0", 200), False, ValueError),
+        ("rgb(a, 0, 260)", False, ValueError),
+        ("2", False, ValueError),
+        ("mybadcolor", False, ValueError),
+    ],
+)
+def test_bad_colors(color, allow_None, expected_exception):
+    """test color validator based on matplotlib validation"""
+
+    with pytest.raises(expected_exception):
+        color_validator(color, allow_None=allow_None)
+
+
+def test_MagicProperties():
+    """test MagicProperties class"""
+
+    class BPsub1(MagicProperties):
+        "MagicProperties class"
+
+        @property
+        def prop1(self):
+            """prop1"""
+            return self._prop1
+
+        @prop1.setter
+        def prop1(self, val):
+            self._prop1 = val
+
+    class BPsub2(MagicProperties):
+        "MagicProperties class"
+
+        @property
+        def prop2(self):
+            """prop2"""
+            return self._prop2
+
+        @prop2.setter
+        def prop2(self, val):
+            self._prop2 = val
+
+    bp1 = BPsub1(prop1=1)
+
+    # check setting attribute/property
+    assert bp1.prop1 == 1, "`bp1.prop1` should be `1`"
+    with pytest.raises(AttributeError):
+        bp1.prop1e = "val"  # only properties are allowed to be set
+
+    assert bp1.as_dict() == {"prop1": 1}, "`as_dict` method failed"
+
+    bp2 = BPsub2(prop2=2)
+    bp1.prop1 = bp2  # assigning class to subproperty
+
+    # check as_dict method
+    assert bp1.as_dict() == {"prop1": {"prop2": 2}}, "`as_dict` method failed"
+
+    # check update method with different parameters
+    assert bp1.update(prop1_prop2=10).as_dict() == {"prop1": {"prop2": 10}}, (
+        "magic property setting failed"
+    )
+
+    with pytest.raises(AttributeError):
+        bp1.update(prop1_prop2=10, prop3=4)
+    assert bp1.update(prop1_prop2=10, prop3=4, _match_properties=False).as_dict() == {
+        "prop1": {"prop2": 10}
+    }, "magic property setting failed, should ignore `'prop3'`"
+
+    assert bp1.update(prop1_prop2=20, _replace_None_only=True).as_dict() == {
+        "prop1": {"prop2": 10}
+    }, "magic property setting failed, `prop2` should be remained unchanged `10`"
+
+    # check copy method
+
+    bp3 = bp2.copy()
+    assert bp3 is not bp2, "failed copying, should return a different id"
+    assert bp3.as_dict() == bp2.as_dict(), (
+        "failed copying, should return the same property values"
+    )
+
+    # check flatten dict
+    assert bp3.as_dict(flatten=True) == bp2.as_dict(flatten=True), (
+        "failed copying, should return the same property values"
+    )
+
+    # check failing init
+    with pytest.raises(AttributeError):
+        BPsub1(a=0)  # `a` is not a property in the class
+
+    # check repr
+    assert repr(MagicProperties()) == "MagicProperties()", "repr failed"
+
+
+def test_get_defaults_dict():
+    """test get_defaults_dict"""
+    s0 = get_defaults_dict("display.style")
+    s1 = get_defaults_dict()["display"]["style"]
+    assert s0 == s1, "dicts don't match"
diff --git a/tests/test_defaults.py b/tests/test_defaults.py
new file mode 100644
index 000000000..44e497c3f
--- /dev/null
+++ b/tests/test_defaults.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+import pytest
+
+import magpylib as magpy
+from magpylib._src.defaults.defaults_classes import DefaultSettings
+from magpylib._src.defaults.defaults_utility import (
+    ALLOWED_LINESTYLES,
+    ALLOWED_SYMBOLS,
+    SUPPORTED_PLOTTING_BACKENDS,
+)
+from magpylib._src.style import DisplayStyle
+
+bad_inputs = {
+    "display_autosizefactor": (0,),  # float>0
+    "display_animation_maxfps": (0,),  # int>0
+    "display_animation_fps": (0,),  # int>0
+    "display_animation_time": (0,),  # int>0
+    "display_animation_maxframes": (0,),  # int>0
+    "display_animation_slider": ("notbool"),  # bool
+    "display_animation_output": ("filename.badext", "badext"),  # bool
+    "display_backend": ("plotty",),  # str typo
+    "display_colorsequence": (["#2E91E5", "wrongcolor"], 123),  # iterable of colors
+    "display_style_base_path_line_width": (-1,),  # float>=0
+    "display_style_base_path_line_style": ("wrongstyle",),
+    "display_style_base_path_line_color": ("wrongcolor",),  # color
+    "display_style_base_path_marker_size": (-1,),  # float>=0
+    "display_style_base_path_marker_symbol": ("wrongsymbol",),
+    "display_style_base_path_marker_color": ("wrongcolor",),  # color
+    "display_style_base_path_show": ("notbool", 1),  # bool
+    "display_style_base_path_frames": (True, False, ["1"], "1"),  # int or iterable
+    "display_style_base_path_numbering": ("notbool",),  # bool
+    "display_style_base_description_show": ("notbool",),  # bool
+    "display_style_base_description_text": (
+        False,
+    ),  # DOES NOT RAISE, transforms everything into str
+    "display_style_base_opacity": (-1,),  # 0<=float<=1
+    "display_style_base_model3d_showdefault": ("notbool",),
+    "display_style_base_color": ("wrongcolor",),  # color
+    "display_style_magnet_magnetization_show": ("notbool",),
+    "display_style_magnet_magnetization_arrow_size": (-1,),  # float>=0
+    "display_style_magnet_magnetization_color_north": ("wrongcolor",),
+    "display_style_magnet_magnetization_color_middle": ("wrongcolor",),
+    "display_style_magnet_magnetization_color_south": ("wrongcolor",),
+    "display_style_magnet_magnetization_color_transition": (-0.2,),  # 0<=float<=1
+    "display_style_magnet_magnetization_color_mode": (
+        "wrongmode",
+    ),  # bicolor, tricolor, tricycle
+    "display_style_magnet_magnetization_mode": (
+        "wrongmode",
+    ),  # 'auto', 'arrow', 'color', 'arrow+color'
+    "display_style_current_arrow_show": ("notbool",),
+    "display_style_current_arrow_size": (-1,),  # float>=0
+    "display_style_current_arrow_width": (-1,),  # float>=0
+    "display_style_sensor_size": (-1,),  # float>=0
+    "display_style_sensor_arrows_x_color": ("wrongcolor",),
+    "display_style_sensor_arrows_x_show": ("notbool",),
+    "display_style_sensor_arrows_y_color": ("wrongcolor",),
+    "display_style_sensor_arrows_y_show": ("notbool",),
+    "display_style_sensor_arrows_z_color": ("wrongcolor",),
+    "display_style_sensor_arrows_z_show": ("notbool",),
+    "display_style_sensor_pixel_size": (-1,),  # float>=0
+    "display_style_sensor_pixel_color": ("notbool",),
+    "display_style_sensor_pixel_symbol": ("wrongsymbol",),
+    "display_style_dipole_size": (-1,),  # float>=0
+    "display_style_dipole_pivot": ("wrongpivot",),  # middle, tail, tip
+    "display_style_triangle_orientation_show": ("notbool",),
+    "display_style_triangle_orientation_size": (-1,),
+    "display_style_triangle_orientation_color": ("wrongcolor",),
+    "display_style_triangle_orientation_offset": ("-1",),  # float, int
+    "display_style_triangle_orientation_symbol": ("arrow0d"),  # "cone", "arrow3d"
+    "display_style_triangularmesh_mesh_disconnected_colorsequence": (1,),
+    "display_style_markers_marker_size": (-1,),  # float>=0
+    "display_style_markers_marker_color": ("wrongcolor",),
+    "display_style_markers_marker_symbol": ("wrongsymbol",),
+}
+
+
+def get_bad_test_data():
+    """create parametrized bad style test data"""
+    # pylint: disable=possibly-used-before-assignment
+    bad_test_data = []
+    for k, tup in bad_inputs.items():
+        for v in tup:
+            if "description_text" not in k:
+                if "color" in k and "transition" not in k and "mode" not in k:
+                    # color attributes use a the color validator, which raises a ValueError
+                    errortype = ValueError
+                else:
+                    # all other parameters raise AssertionError
+                    errortype = AssertionError
+            bad_test_data.append((k, v, pytest.raises(errortype)))
+    return bad_test_data
+
+
+@pytest.mark.parametrize(
+    ("key", "value", "expected_errortype"),
+    get_bad_test_data(),
+)
+def test_defaults_bad_inputs(key, value, expected_errortype):
+    """testing defaults setting on bad inputs"""
+    c = DefaultSettings().reset()
+    with expected_errortype:
+        c.update(**{key: value})
+
+
+# dict of good input.
+# This is just for check. dict keys should not be tuples in general, but the test will iterate
+# over the values for each key
+good_inputs = {
+    "display_autosizefactor": (1,),  # float>0
+    "display_animation_maxfps": (10,),  # int>0
+    "display_animation_fps": (10,),  # int>0
+    "display_animation_time": (10,),  # int>0
+    "display_animation_maxframes": (200,),  # int>0
+    "display_animation_slider": (True, False),  # bool
+    "display_animation_output": ("filename.mp4", "gif"),  # bool
+    "display_backend": ["auto", *SUPPORTED_PLOTTING_BACKENDS],  # str typo
+    "display_colorsequence": (
+        ("#2e91e5", "#0d2a63"),
+        ("blue", "red"),
+    ),  # ]),  # iterable of colors
+    "display_style_base_path_line_width": (0, 1),  # float>=0
+    "display_style_base_path_line_style": ALLOWED_LINESTYLES,
+    "display_style_base_path_line_color": ("blue", "#2E91E5"),  # color
+    "display_style_base_path_marker_size": (0, 1),  # float>=0
+    "display_style_base_path_marker_symbol": ALLOWED_SYMBOLS,
+    "display_style_base_path_marker_color": ("blue", "#2E91E5"),  # color
+    "display_style_base_path_show": (True, False),  # bool
+    "display_style_base_path_frames": (-1, (1, 3)),  # int or iterable
+    "display_style_base_path_numbering": (True, False),  # bool
+    "display_style_base_description_show": (True, False),  # bool
+    "display_style_base_description_text": ("a string",),  # string
+    "display_style_base_opacity": (0, 0.5, 1),  # 0<=float<=1
+    "display_style_base_model3d_showdefault": (True, False),
+    "display_style_base_color": ("blue", "#2E91E5"),  # color
+    "display_style_magnet_magnetization_show": (True, False),
+    "display_style_magnet_magnetization_size": (0, 1),  # float>=0
+    "display_style_magnet_magnetization_color_north": ("blue", "#2E91E5"),
+    "display_style_magnet_magnetization_color_middle": ("blue", "#2E91E5"),
+    "display_style_magnet_magnetization_color_south": ("blue", "#2E91E5"),
+    "display_style_magnet_magnetization_color_transition": (0, 0.5, 1),  # 0<=float<=1
+    "display_style_magnet_magnetization_color_mode": (
+        "bicolor",
+        "tricolor",
+        "tricycle",
+    ),
+    "display_style_magnet_magnetization_mode": (
+        "auto",
+        "arrow",
+        "color",
+        "arrow+color",
+        "color+arrow",
+    ),
+    "display_style_current_arrow_show": (True, False),
+    "display_style_current_arrow_size": (0, 1),  # float>=0
+    "display_style_current_arrow_width": (0, 1),  # float>=0
+    "display_style_sensor_size": (0, 1),  # float>=0
+    "display_style_sensor_arrows_x_color": ("magenta",),
+    "display_style_sensor_arrows_x_show": (True, False),
+    "display_style_sensor_arrows_y_color": ("yellow",),
+    "display_style_sensor_arrows_y_show": (True, False),
+    "display_style_sensor_arrows_z_color": ("cyan",),
+    "display_style_sensor_arrows_z_show": (True, False),
+    "display_style_sensor_pixel_size": (0, 1),  # float>=0
+    "display_style_sensor_pixel_color": ("blue", "#2E91E5"),
+    "display_style_sensor_pixel_symbol": ALLOWED_SYMBOLS,
+    "display_style_dipole_size": (0, 1),  # float>=0
+    "display_style_dipole_pivot": (
+        "middle",
+        "tail",
+        "tip",
+    ),  # pivot middle, tail, tip
+    "display_style_triangle_orientation_show": (True, False),
+    "display_style_triangle_orientation_size": (True, False),
+    "display_style_triangle_orientation_color": ("yellow",),
+    "display_style_triangle_orientation_offset": (-1, 0.5, 2),  # float, int
+    "display_style_triangle_orientation_symbol": ("cone", "arrow3d"),
+    "display_style_markers_marker_size": (0, 1),  # float>=0
+    "display_style_markers_marker_color": ("blue", "#2E91E5"),
+    "display_style_markers_marker_symbol": ALLOWED_SYMBOLS,
+}
+
+
+def get_good_test_data():
+    """create parametrized good style test data"""
+    good_test_data = []
+    for key, tup in good_inputs.items():
+        for value in tup:
+            expected = value
+            if "color" in key and isinstance(value, str):
+                expected = value.lower()  # hex color gets lowered
+            good_test_data.append((key, value, expected))
+    return good_test_data
+
+
+@pytest.mark.parametrize(
+    ("key", "value", "expected"),
+    get_good_test_data(),
+)
+def test_defaults_good_inputs(key, value, expected):
+    """testing defaults setting on bad inputs"""
+    c = DefaultSettings()
+    c.update(**{key: value})
+    v0 = c
+    for v in key.split("_"):
+        v0 = getattr(v0, v)
+    assert v0 == expected, f"{key} should be {expected}, but received {v0} instead"
+
+
+@pytest.mark.parametrize(
+    "style_class",
+    [
+        "base",
+        "base_model3d",
+        "base_path",
+        "base_path_line",
+        "base_path_marker",
+        "current",
+        "current_arrow",
+        "dipole",
+        "magnet",
+        "magnet_magnetization",
+        "magnet_magnetization_color",
+        "markers",
+        "markers_marker",
+        "sensor",
+        "sensor_pixel",
+    ],
+)
+def test_bad_style_classes(style_class):
+    """testing properties which take classes as properties"""
+    c = DisplayStyle().reset()
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"the `.*` property of `.*` must be an instance \nof `<class '.*'>` or a "
+            r"dictionary with equivalent key/value pairs \nbut received 'bad class' instead"
+        ),
+    ):
+        c.update(**{style_class: "bad class"})
+
+
+def test_bad_default_classes():
+    """testing properties which take classes as properties"""
+    with pytest.raises(
+        ValueError,
+        match=r"the `display` property of `DefaultSettings` must be.*",
+    ):
+        magpy.defaults.display = "wrong input"
+    with pytest.raises(
+        ValueError,
+        match=r"the `animation` property of `Display` must be.*",
+    ):
+        magpy.defaults.display.animation = "wrong input"
+    with pytest.raises(
+        ValueError,
+        match=r"the `style` property of `Display` must be.*",
+    ):
+        magpy.defaults.display.style = "wrong input"
+
+
+def test_bad_deferred_style():
+    """test error raise on deferred style attribution"""
+    c = magpy.magnet.Cuboid(style_badstyle="ASDF")
+    with pytest.raises(
+        AttributeError,
+        match=r".* has been initialized with some invalid style arguments.*",
+    ):
+        magpy.show(c)  # style property gets called, style kwargs applied
diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py
new file mode 100644
index 000000000..6a147e9e0
--- /dev/null
+++ b/tests/test_display_matplotlib.py
@@ -0,0 +1,643 @@
+# pylint: disable="wrong-import-position"
+from __future__ import annotations
+
+import re
+from unittest.mock import patch
+
+import matplotlib as mpl  # noreorder
+
+mpl.use("Agg")
+import matplotlib.pyplot as plt
+import matplotlib.tri as mtri
+import numpy as np
+import pytest
+import pyvista as pv
+from matplotlib.figure import Figure as mplFig
+
+import magpylib as magpy
+from magpylib._src.display.display import ctx
+from magpylib.graphics.model3d import make_Cuboid
+
+# pylint: disable=assignment-from-no-return
+# pylint: disable=unnecessary-lambda-assignment
+# pylint: disable=no-member
+
+magpy.defaults.reset()
+
+
+def test_Cuboid_display():
+    """testing display"""
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.1, 0.1, 0.1), (2, 2, 2), 20), start=-1)
+    src.show(
+        style_path_frames=5,
+        style_magnetization_arrow_sizemode="absolute",
+        style_magnetization_arrow_color="cyan",
+        style_magnetization_arrow_style="dashed",
+        style_magnetization_arrow_width=3,
+        return_fig=True,
+    )
+
+    with patch("matplotlib.pyplot.show"):
+        x = src.show(style_path_show=False, style_magnetization_mode="color+arrow")
+    assert x is None  # only place where return_fig=False, for testcov
+
+
+def test_Cylinder_display():
+    """testing display"""
+    # path should revert to True
+    ax = plt.subplot(projection="3d")
+    src = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    src.show(canvas=ax, style_path_frames=15, backend="matplotlib")
+
+    # hide path
+    src.move(np.linspace((0.4, 0.4, 0.4), (12, 12, 12), 30), start=-1)
+    src.show(canvas=ax, style_path_show=False, backend="matplotlib")
+
+    # empty frames, ind>path_len, should display last position
+    src.show(canvas=ax, style_path_frames=[], backend="matplotlib")
+
+    src.show(
+        canvas=ax,
+        style_path_frames=[1, 5, 6],
+        style_path_numbering=True,
+        backend="matplotlib",
+        return_fig=True,
+    )
+
+
+def test_CylinderSegment_display():
+    """testing display"""
+    ax = plt.subplot(projection="3d")
+    src = magpy.magnet.CylinderSegment(
+        polarization=(1, 2, 3), dimension=(2, 4, 5, 30, 40)
+    )
+    src.show(canvas=ax, style_path_frames=15, return_fig=True)
+
+    src.move(np.linspace((0.4, 0.4, 0.4), (13.2, 13.2, 13.2), 33), start=-1)
+    src.show(canvas=ax, style_path_show=False, return_fig=True)
+
+
+def test_Sphere_display():
+    """testing display"""
+    # path should revert to True
+    ax = plt.subplot(projection="3d")
+    src = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=2)
+    src.show(canvas=ax, style_path_frames=15, return_fig=True)
+
+    src.move(np.linspace((0.4, 0.4, 0.4), (8, 8, 8), 20), start=-1)
+    src.show(
+        canvas=ax,
+        style_path_show=False,
+        style_magnetization_mode="color+arrow",
+        return_fig=True,
+    )
+
+
+def test_Tetrahedron_display():
+    """testing Tetrahedron display"""
+    verts = [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]
+    src = magpy.magnet.Tetrahedron(polarization=(0.1, 0.2, 0.3), vertices=verts)
+    src.show(return_fig=True, style_magnetization_mode="color+arrow")
+
+
+def test_Sensor_display():
+    """testing display"""
+    ax = plt.subplot(projection="3d")
+    sens = magpy.Sensor(pixel=[(1, 2, 3), (2, 3, 4)], handedness="left")
+    sens.style.arrows.z.color = "magenta"
+    sens.style.arrows.z.show = False
+    poz = np.linspace((0.4, 0.4, 0.4), (13.2, 13.2, 13.2), 33)
+    sens.move(poz, start=-1)
+    sens.show(
+        canvas=ax, markers=[(100, 100, 100)], style_path_frames=15, return_fig=True
+    )
+
+    sens.pixel = [(2, 3, 4)]  # one non-zero pixel
+    sens.show(
+        canvas=ax, markers=[(100, 100, 100)], style_path_show=False, return_fig=True
+    )
+
+
+def test_CustomSource_display():
+    """testing display"""
+    ax = plt.subplot(projection="3d")
+    cs = magpy.misc.CustomSource()
+    cs.show(canvas=ax, return_fig=True)
+
+
+def test_Circle_display():
+    """testing display for Circle source"""
+    ax = plt.subplot(projection="3d")
+    src = magpy.current.Circle(current=1, diameter=1)
+    src.show(canvas=ax, return_fig=True)
+
+    src.rotate_from_angax([5] * 35, "x", anchor=(1, 2, 3))
+    src.show(canvas=ax, style_path_frames=3, return_fig=True)
+
+
+def test_Triangle_display():
+    """testing display for Triangle source built from vertices"""
+    mesh3d = magpy.graphics.model3d.make_Cuboid()
+    # note: triangles are built by scipy.Convexhull since triangles=None
+    points = np.array([v for k, v in mesh3d["kwargs"].items() if k in "xyz"]).T
+    triangles = np.array([v for k, v in mesh3d["kwargs"].items() if k in "ijk"]).T
+    src = magpy.Collection(
+        [
+            magpy.misc.Triangle(polarization=(1, 1, 0), vertices=v)
+            for v in points[triangles]
+        ]
+    )
+    # make north/south limit pass an edge by bicolor mode and (45° mag)
+    magpy.show(
+        *src,
+        backend="matplotlib",
+        style_magnetization_color_mode="bicolor",
+        style_orientation_offset=0.5,
+        style_orientation_size=2,
+        style_orientation_color="yellow",
+        style_orientation_symbol="cone",
+        style_magnetization_mode="color+arrow",
+        return_fig=True,
+    )
+
+
+def test_Triangle_display_from_convexhull():
+    """testing display for Triangle source built from vertices"""
+    verts = [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]
+
+    mesh3d = magpy.graphics.model3d.make_TriangularMesh(vertices=verts)
+    # note: faces are built by scipy.Convexhull since faces=None
+    # ConvexHull DOES NOT GUARANTY proper orientation of faces when building a body
+    points = np.array([v for k, v in mesh3d["kwargs"].items() if k in "xyz"]).T
+    faces = np.array([v for k, v in mesh3d["kwargs"].items() if k in "ijk"]).T
+    src = magpy.Collection(
+        [magpy.misc.Triangle(polarization=(1, 0, 0), vertices=v) for v in points[faces]]
+    )
+    magpy.show(
+        *src,
+        backend="matplotlib",
+        style_orientation_offset=0.5,
+        style_orientation_size=2,
+        style_orientation_color="yellow",
+        style_orientation_symbol="cone",
+        style_magnetization_mode="color+arrow",
+        return_fig=True,
+    )
+
+
+def test_TriangularMesh_display():
+    """testing display for TriangleMesh source built from vertices"""
+    # test  classic trimesh display
+    points = [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]
+
+    src = magpy.magnet.TriangularMesh.from_ConvexHull(
+        polarization=(1, 0, 0), points=points
+    )
+    src.show(
+        backend="matplotlib",
+        style_description_show=False,
+        style_mesh_open_show=True,
+        style_mesh_disconnected_show=True,
+        style_mesh_selfintersecting_show=True,
+        style_orientation_show=True,
+        return_fig=True,
+    )
+
+    # test display of disconnected and open mesh elements
+    polydata = pv.Text3D("AB")  # create disconnected mesh
+    polydata = polydata.triangulate()
+    vertices = polydata.points
+    faces = polydata.faces.reshape(-1, 4)[:, 1:]
+    faces = faces[1:]  # open the mesh
+    src = magpy.magnet.TriangularMesh(
+        polarization=(0, 0, 1000),
+        vertices=vertices,
+        faces=faces,
+        check_open="ignore",
+        check_disconnected="ignore",
+        reorient_faces=False,
+        style_mesh_grid_show=True,
+    )
+
+    src.show(
+        style_mesh_open_show=True,
+        style_mesh_disconnected_show=True,
+        style_orientation_show=True,
+        style_magnetization_mode="color+arrow",
+        backend="matplotlib",
+        return_fig=True,
+    )
+
+    with pytest.warns(UserWarning) as record:
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1000),
+            vertices=vertices,
+            faces=faces,
+            check_open="skip",
+            check_disconnected="skip",
+            reorient_faces=False,
+            style_mesh_grid_show=True,
+        ).show(
+            style_mesh_open_show=True,
+            style_mesh_disconnected_show=True,
+            backend="matplotlib",
+            return_fig=True,
+        )
+        assert len(record) == 4
+        assert re.match(
+            r"Unchecked open mesh status in .* detected", str(record[0].message)
+        )
+        assert re.match(r"Open mesh detected in .*.", str(record[1].message))
+        assert re.match(
+            r"Unchecked disconnected mesh status in .* detected", str(record[2].message)
+        )
+        assert re.match(r"Disconnected mesh detected in .*.", str(record[3].message))
+
+    # test self-intersecting display
+    selfintersecting_mesh3d = {
+        "x": [-1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 0.0],
+        "y": [-1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 0.0],
+        "z": [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -2.0],
+        "i": [7, 0, 0, 0, 2, 6, 4, 0, 3, 7, 4, 5, 6, 7],
+        "j": [0, 7, 1, 2, 1, 2, 5, 5, 2, 2, 5, 6, 7, 4],
+        "k": [3, 4, 2, 3, 5, 5, 0, 1, 7, 6, 8, 8, 8, 8],
+    }
+    vertices = np.array([v for k, v in selfintersecting_mesh3d.items() if k in "xyz"]).T
+    faces = np.array([v for k, v in selfintersecting_mesh3d.items() if k in "ijk"]).T
+    with pytest.warns(UserWarning) as record:
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1000),
+            vertices=vertices,
+            faces=faces,
+            check_open="warn",
+            check_disconnected="warn",
+            check_selfintersecting="skip",
+            reorient_faces=True,
+        ).show(
+            style_mesh_selfintersecting_show=True,
+            backend="matplotlib",
+            return_fig=True,
+        )
+        assert len(record) == 2
+        assert re.match(
+            r"Unchecked selfintersecting mesh status in .* detected",
+            str(record[0].message),
+        )
+        assert re.match(
+            r"Self-intersecting mesh detected in .*.", str(record[1].message)
+        )
+
+
+def test_col_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    ax = plt.subplot(projection="3d")
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = pm1.copy(position=(2, 0, 0))
+    pm3 = pm1.copy(position=(4, 0, 0))
+    nested_col = (pm1 + pm2 + pm3).set_children_styles(color="magenta")
+    nested_col.show(canvas=ax, return_fig=True)
+
+
+def test_dipole_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    ax2 = plt.subplot(projection="3d")
+    dip = magpy.misc.Dipole(moment=(1, 2, 3), position=(2, 2, 2))
+    dip2 = magpy.misc.Dipole(moment=(1, 2, 3), position=(2, 2, 2))
+    dip2.move(np.linspace((0.4, 0.4, 0.4), (2, 2, 2), 5), start=-1)
+    dip.show(canvas=ax2, return_fig=True)
+    dip.show(canvas=ax2, style_path_frames=2, return_fig=True)
+
+
+def test_circular_line_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    ax2 = plt.subplot(projection="3d")
+    src1 = magpy.current.Circle(current=1, diameter=2)
+    src2 = magpy.current.Circle(current=1, diameter=2)
+    src1.move(np.linspace((0.4, 0.4, 0.4), (2, 2, 2), 5), start=-1)
+    src3 = magpy.current.Polyline(current=1, vertices=[(0, 0, 0), (1, 1, 1), (2, 2, 2)])
+    src4 = magpy.current.Polyline(current=1, vertices=[(0, 0, 0), (1, 1, 1), (2, 2, 2)])
+    src3.move([(0.4, 0.4, 0.4)] * 5, start=-1)
+    src1.show(canvas=ax2, style_path_frames=2, style_arrow_size=0, return_fig=True)
+    src2.show(canvas=ax2, style_arrow_sizemode="absolute", return_fig=True)
+    src3.show(
+        canvas=ax2, style_arrow_sizemode="absolute", style_arrow_size=0, return_fig=True
+    )
+    src4.show(canvas=ax2, style_path_frames=2, return_fig=True)
+
+
+def test_matplotlib_model3d_extra():
+    """test display extra model3d"""
+
+    # using "plot"
+    xs, ys, zs = [(1, 2)] * 3
+    trace1 = {
+        "backend": "matplotlib",
+        "constructor": "plot",
+        "args": (xs, ys, zs),
+        "kwargs": {"ls": "-"},
+    }
+    obj1 = magpy.misc.Dipole(moment=(0, 0, 1))
+    obj1.style.model3d.add_trace(**trace1)
+
+    # using "plot_surface"
+    u, v = np.mgrid[0 : 2 * np.pi : 6j, 0 : np.pi : 6j]
+    xs = np.cos(u) * np.sin(v)
+    ys = np.sin(u) * np.sin(v)
+    zs = np.cos(v)
+    trace2 = {
+        "backend": "matplotlib",
+        "constructor": "plot_surface",
+        "args": (xs, ys, zs),
+        "kwargs": {"cmap": plt.cm.YlGnBu_r},  # pylint: disable=no-member},
+    }
+    obj2 = magpy.Collection()
+    obj2.style.model3d.add_trace(**trace2)
+
+    # using "plot_trisurf"
+    u, v = np.mgrid[0 : 2 * np.pi : 6j, -0.5:0.5:6j]
+    u, v = u.flatten(), v.flatten()
+    xs = (1 + 0.5 * v * np.cos(u / 2.0)) * np.cos(u)
+    ys = (1 + 0.5 * v * np.cos(u / 2.0)) * np.sin(u)
+    zs = 0.5 * v * np.sin(u / 2.0)
+    tri = mtri.Triangulation(u, v)
+    trace3 = {
+        "backend": "matplotlib",
+        "constructor": "plot_trisurf",
+        "args": lambda: (xs, ys, zs),  # test callable args,
+        "kwargs": {
+            "triangles": tri.triangles,
+            "cmap": plt.cm.Spectral,  # pylint: disable=no-member
+        },
+    }
+    obj3 = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(3, 0, 0))
+    obj3.style.model3d.add_trace(**trace3)
+
+    ax = plt.subplot(projection="3d")
+    magpy.show(obj1, obj2, obj3, canvas=ax, return_fig=True)
+
+
+def test_matplotlib_model3d_extra_bad_input():
+    """test display extra model3d"""
+
+    xs, ys, zs = [(1, 2)] * 3
+    trace = {
+        "backend": "matplotlib",
+        "constructor": "plot",
+        "kwargs": {"xs": xs, "ys": ys, "zs": zs},
+        "coordsargs": {"x": "xs", "y": "ys", "z": "Z"},  # bad Z input
+    }
+    obj = magpy.misc.Dipole(moment=(0, 0, 1))
+    obj.style.model3d.add_trace(**trace)
+    ax = plt.subplot(projection="3d")
+    with pytest.raises(
+        ValueError,
+        match=r"Rotating/Moving of provided model failed, trace dictionary has no argument 'z',.*",
+    ):
+        obj.show(canvas=ax, return_fig=True)
+
+
+def test_matplotlib_model3d_extra_updatefunc():
+    """test display extra model3d"""
+    obj = magpy.misc.Dipole(moment=(0, 0, 1))
+
+    def updatefunc():
+        return make_Cuboid("matplotlib", position=(2, 0, 0))
+
+    obj.style.model3d.data = updatefunc
+    ax = plt.subplot(projection="3d")
+    obj.show(canvas=ax, return_fig=True)
+
+    updatefunc = "not callable"
+    with pytest.raises(
+        ValueError, match=(r"the `data` property of `Model3d` must be an instance.*")
+    ):
+        obj.style.model3d.add_trace(updatefunc)
+
+    updatefunc = "not callable"
+    with pytest.raises(AssertionError):
+        obj.style.model3d.add_trace(updatefunc=updatefunc)
+
+    def updatefunc():
+        return "bad output type"
+
+    with pytest.raises(AssertionError):
+        obj.style.model3d.add_trace(updatefunc=updatefunc)
+
+    def updatefunc():
+        return {"bad_key": "some_value"}
+
+    with pytest.raises(AssertionError):
+        obj.style.model3d.add_trace(updatefunc=updatefunc)
+
+
+def test_empty_display():
+    """should not fail if nothing to display"""
+    magpy.show(backend="matplotlib", return_fig=True)
+
+
+def test_graphics_model_mpl():
+    """test base extra graphics with mpl"""
+    ax = plt.subplot(projection="3d")
+    c = magpy.magnet.Cuboid(polarization=(0, 1, 0), dimension=(1, 1, 1))
+    c.rotate_from_angax(33, "x", anchor=0)
+    c.style.model3d.add_trace(**make_Cuboid("matplotlib", position=(2, 0, 0)))
+    c.show(canvas=ax, style_path_frames=1, backend="matplotlib", return_fig=True)
+
+
+def test_graphics_model_generic_to_mpl():
+    """test generic base extra graphics with mpl"""
+    c = magpy.magnet.Cuboid(polarization=(0, 1, 0), dimension=(1, 1, 1))
+    c.move([[i, 0, 0] for i in range(2)])
+    model3d = make_Cuboid(position=(2, 0, 0))
+    model3d["kwargs"]["facecolor"] = np.array(["blue"] * 12)
+    c.style.model3d.add_trace(**model3d)
+    fig = c.show(style_path_frames=1, backend="matplotlib", return_fig=True)
+    assert isinstance(fig, mpl.figure.Figure)
+
+
+def test_mpl_animation():
+    """test animation with matplotib"""
+    c = magpy.magnet.Cuboid(polarization=(0, 1, 0), dimension=(1, 1, 1))
+    c.move([[i, 0, 0] for i in range(2)])
+    fig, anim = c.show(
+        backend="matplotlib", animation=True, return_animation=True, return_fig=True
+    )
+    # pylint: disable=protected-access
+    anim._draw_was_started = True  # avoid mpl test warning
+    assert isinstance(fig, mpl.figure.Figure)
+    assert isinstance(anim, mpl.animation.FuncAnimation)
+
+
+def test_subplots():
+    """test subplots"""
+    sensor = magpy.Sensor(
+        pixel=np.linspace((0, 0, -0.2), (0, 0, 0.2), 2), style_size=1.5
+    )
+    sensor.style.label = "Sensor1"
+    cyl1 = magpy.magnet.Cylinder(
+        polarization=(0.1, 0, 0), dimension=(1, 2), style_label="Cylinder1"
+    )
+
+    # define paths
+    sensor.position = np.linspace((0, 0, -3), (0, 0, 3), 100)
+    cyl1.position = (4, 0, 0)
+    cyl1.rotate_from_angax(angle=np.linspace(0, 300, 100), start=0, axis="z", anchor=0)
+    cyl2 = cyl1.copy().move((0, 0, 5))
+    objs = cyl1, cyl2, sensor
+
+    # with implicit axes
+    fig = plt.figure(figsize=(20, 4))
+    with magpy.show_context(
+        backend="matplotlib", canvas=fig, animation=False, sumup=True, pixel_agg="mean"
+    ) as s:
+        s.show(*objs, col=1, output=("Bx", "By", "Bz"))  # from context
+        magpy.show(cyl1, col=2)  # directly
+        magpy.show({"objects": [cyl1, cyl2], "col": 3})  # as dict
+
+    # with given axes in figure
+    fig = plt.figure(figsize=(20, 4))
+    fig.add_subplot(121, projection="3d")
+    magpy.show(cyl1, col=2, canvas=fig)
+
+
+def test_bad_show_inputs():
+    """bad show inputs"""
+
+    cyl1 = magpy.magnet.Cylinder(
+        polarization=(0.1, 0, 0), dimension=(1, 2), style_label="Cylinder1"
+    )
+
+    # test bad canvas
+    with pytest.raises(TypeError, match=r"The `canvas` parameter must be one of .*"):
+        magpy.show(cyl1, canvas="bad_canvas_input", backend="matplotlib")
+
+    # test bad axes canvas with rows
+    fig = plt.figure(figsize=(20, 4))
+    ax = fig.add_subplot(131, projection="3d")
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"Provided canvas is an instance of `matplotlib.axes.Axes` "
+            r"and does not support `rows`.*"
+        ),
+    ):
+        magpy.show(cyl1, canvas=ax, col=2, backend="matplotlib")
+
+    # test conflicting output types
+    sensor = magpy.Sensor(
+        pixel=np.linspace((0, 0, -0.2), (0, 0, 0.2), 2), style_size=1.5
+    )
+    cyl1 = magpy.magnet.Cylinder(
+        polarization=(0.1, 0, 0), dimension=(1, 2), style_label="Cylinder1"
+    )
+    with pytest.raises(  # noqa: PT012, SIM117
+        ValueError,
+        match=(
+            r"Conflicting parameters detected for {'row': 1, 'col': 1}:"
+            r" 'output' first got 'model3d' then 'Bx'."
+        ),
+    ):
+        with magpy.show_context(animation=False, sumup=True, pixel_agg="mean") as s:
+            s.show(cyl1, sensor, col=1, output="Bx")
+            s.show(cyl1, sensor, col=1)
+
+    # test unsupported specific args for some backends
+    with pytest.warns(
+        UserWarning,
+        match=r"The 'plotly' backend does not support 'animation_output'.*",
+    ):
+        sensor = magpy.Sensor(
+            position=np.linspace((0, 0, -0.2), (0, 0, 0.2), 200), style_size=1.5
+        )
+        magpy.show(
+            sensor,
+            backend="plotly",
+            col=1,
+            animation=True,
+            animation_output="gif",
+            return_fig=True,
+        )
+
+
+def test_show_context_reset():
+    """show context reset"""
+    ctx.reset(reset_show_return_value=True)
+    with magpy.show_context(backend="matplotlib") as s:
+        assert s.show_return_value is None
+        s.show(magpy.Sensor(), return_fig=True)
+    assert isinstance(s.show_return_value, mplFig)
+
+
+def test_unset_excitations():
+    """test show if mag, curr or mom are not set"""
+
+    objs = [
+        magpy.magnet.Cuboid(dimension=(1, 1, 1)),
+        magpy.magnet.Cylinder(dimension=(1, 1)),
+        magpy.magnet.CylinderSegment(dimension=(0, 1, 1, 45, 120)),
+        magpy.magnet.Tetrahedron(vertices=[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]),
+        magpy.magnet.TriangularMesh(
+            vertices=((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)),
+            faces=((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)),
+        ),
+        magpy.magnet.Sphere(diameter=1),
+        magpy.misc.Triangle(vertices=[(0, 0, 0), (1, 0, 0), (0, 1, 0)]),
+        magpy.misc.Dipole(),
+        magpy.current.Polyline(vertices=[[0, -1, 0], [0, 1, 0]]),
+        magpy.current.Circle(diameter=1, current=0),
+    ]
+    for i, o in enumerate(objs):
+        o.move((i * 1.5, 0, 0))
+    magpy.show(
+        *objs,
+        style_magnetization_mode="color+arrow",
+        return_fig=True,
+    )
+
+
+def test_unset_objs():
+    """test completely unset objects"""
+    objs = [
+        magpy.magnet.Cuboid(),
+        magpy.magnet.Cylinder(),
+        magpy.magnet.CylinderSegment(),
+        magpy.magnet.Sphere(),
+        magpy.magnet.Tetrahedron(),
+        # magpy.magnet.TriangularMesh(), not possible yet
+        magpy.misc.Triangle(),
+        magpy.misc.Dipole(),
+        magpy.current.Polyline(),
+        magpy.current.Circle(),
+    ]
+
+    for i, o in enumerate(objs):
+        o.move((1.5 * i, 0, 0))
+    magpy.show(
+        *objs,
+        return_fig=True,
+    )
+
+
+def test_show_legend():
+    """test legend (and multi shape pixel)"""
+    pixel = np.arange(27).reshape(3, 3, 3) * 1e-2
+    s1 = magpy.Sensor(pixel=pixel, style_label="s1")
+    s2 = s1.copy().move((1, 0, 0))
+    s3 = s2.copy().move((1, 0, 0))
+    s2.style.legend = "full legend replace"
+    s3.style.description = "description replace only"
+    magpy.show(s1, s2, s3, return_fig=True)
+
+
+@pytest.mark.parametrize("units_length", ["mT", "inch", "dam", "e"])
+def test_bad_units_length(units_length):
+    """test units lengths"""
+
+    c = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+
+    with pytest.raises(ValueError, match=r"Invalid unit input.*"):
+        c.show(units_length=units_length, return_fig=True, backend="matplotlib")
diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py
new file mode 100644
index 000000000..33050a33a
--- /dev/null
+++ b/tests/test_display_plotly.py
@@ -0,0 +1,488 @@
+from __future__ import annotations
+
+import numpy as np
+import plotly.graph_objects as go
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.utility import get_unit_factor
+
+# pylint: disable=assignment-from-no-return
+# pylint: disable=no-member
+
+
+def test_Cylinder_display():
+    """testing display"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    src = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    x = src.show(canvas=fig, style_path_frames=15)
+    assert x is None, "path should revert to True"
+
+    src.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1)
+    x = src.show(
+        canvas=fig,
+        style_path_show=False,
+        style_magnetization_show=True,
+        style_magnetization_color_mode="tricycle",
+    )
+    assert x is None, "display test fail"
+
+
+def test_CylinderSegment_display():
+    """testing display"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    src = magpy.magnet.CylinderSegment(
+        polarization=(1, 2, 3), dimension=(2, 4, 5, 30, 40)
+    )
+    x = src.show(canvas=fig, style_path_frames=15)
+    assert x is None, "path should revert to True"
+
+    src.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1)
+    x = src.show(
+        canvas=fig,
+        style_path_show=False,
+        style_magnetization_show=True,
+        style_magnetization_color_mode="bicolor",
+    )
+    assert x is None, "display test fail"
+
+
+def test_Sphere_display():
+    """testing display"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    src = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=2)
+    x = src.show(canvas=fig, style_path_frames=15)
+    assert x is None, "path should revert to True"
+
+    src.move(np.linspace((0.4, 0.4, 0.4), (8, 8, 8), 33), start=-1)
+    x = src.show(canvas=fig, style_path_show=False, style_magnetization_show=True)
+    assert x is None, "display test fail"
+
+
+def test_Cuboid_display():
+    """testing display"""
+    magpy.defaults.display.backend = "plotly"
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.1, 0.1, 0.1), (2, 2, 2), 20), start=-1)
+    x = src.show(style_path_frames=5, style_magnetization_show=True, renderer="json")
+    assert x is None, "display test fail"
+
+    fig = go.Figure()
+    x = src.show(canvas=fig, style_path_show=False, style_magnetization_show=True)
+    assert x is None, "display test fail"
+
+
+def test_Sensor_display():
+    """testing display"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    sens_nopix = magpy.Sensor()
+    x = sens_nopix.show(canvas=fig, style_description_text="mysensor")
+    assert x is None, "display test fail"
+    sens = magpy.Sensor(pixel=[(1, 2, 3), (2, 3, 4)])
+    sens.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1)
+    sens.style.arrows.z.color = "magenta"
+    sens.style.arrows.z.show = False
+    x = sens.show(canvas=fig, markers=[(100, 100, 100)], style_path_frames=15)
+    assert x is None, "display test fail"
+    x = sens.show(canvas=fig, markers=[(100, 100, 100)], style_path_show=False)
+    assert x is None, "display test fail"
+
+
+def test_Circle_display():
+    """testing display for Circle source"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    src = magpy.current.Circle(current=1, diameter=1)
+    x = src.show(canvas=fig)
+    assert x is None, "display test fail"
+
+    src.rotate_from_angax([5] * 35, "x", anchor=(1, 2, 3))
+    x = src.show(canvas=fig, style_path_frames=3)
+    assert x is None, "display test fail"
+
+
+def test_Triangle_display():
+    """testing display for Triangle source"""
+    # this test is necessary to cover the case where the backend can display mag arrows and
+    # color gradient must be deactivated
+    verts = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
+    src = magpy.misc.Triangle(polarization=(0.1, 0.2, 0.3), vertices=verts)
+    src.show(style_magnetization_mode="arrow", return_fig=True)
+
+
+def test_col_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = pm1.copy(position=(2, 0, 0))
+    pm3 = pm1.copy(position=(4, 0, 0))
+    nested_col = (pm1 + pm2 + pm3).set_children_styles(color="magenta")
+    x = nested_col.show(canvas=fig)
+    assert x is None, "collection display test fail"
+
+
+def test_dipole_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    dip1 = magpy.misc.Dipole(moment=(1, 2, 3), position=(1, 1, 1))
+    dip2 = magpy.misc.Dipole(moment=(1, 2, 3), position=(2, 2, 2))
+    dip3 = magpy.misc.Dipole(moment=(1, 2, 3), position=(3, 3, 3))
+    dip2.move(np.linspace((0.4, 0.4, 0.4), (2, 2, 2), 5), start=-1)
+    x = dip1.show(canvas=fig, style_pivot="tail")
+    assert x is None, "display test fail"
+    x = dip2.show(canvas=fig, style_path_frames=2, style_pivot="tip")
+    assert x is None, "display test fail"
+    x = dip3.show(canvas=fig, style_path_frames=2, style_pivot="middle")
+    assert x is None, "display test fail"
+
+
+def test_circular_line_display():
+    """testing display"""
+    # pylint: disable=assignment-from-no-return
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    src1 = magpy.current.Circle(current=1, diameter=2)
+    src2 = magpy.current.Circle(current=1, diameter=2)
+    src1.move(np.linspace((0.4, 0.4, 0.4), (2, 2, 2), 5), start=-1)
+    src3 = magpy.current.Polyline(current=1, vertices=[(0, 0, 0), (1, 1, 1), (2, 2, 2)])
+    src4 = magpy.current.Polyline(current=1, vertices=[(0, 0, 0), (1, 1, 1), (2, 2, 2)])
+    src3.move([(0.4, 0.4, 0.4)] * 5, start=-1)
+    x = src1.show(canvas=fig, style_path_frames=2, style_arrow_show=False)
+    assert x is None, "display test fail"
+    x = src2.show(canvas=fig)
+    assert x is None, "display test fail"
+    x = src3.show(canvas=fig, style_arrow_show=False)
+    assert x is None, "display test fail"
+    x = src4.show(canvas=fig, style_path_frames=2)
+    assert x is None, "display test fail"
+
+
+def test_display_bad_style_kwargs():
+    """test if some magic kwargs are invalid"""
+    magpy.defaults.display.backend = "plotly"
+    fig = go.Figure()
+    with pytest.raises(
+        ValueError,
+        match=r"Following arguments are invalid style properties: `{'bad_style_kwarg'}`.*",
+    ):
+        magpy.show(canvas=fig, markers=[(1, 2, 3)], style_bad_style_kwarg=None)
+
+
+def test_extra_model3d():
+    """test display when object has an extra model object attached"""
+    magpy.defaults.display.backend = "plotly"
+    cuboid = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    cuboid.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1)
+    cuboid.style.model3d.showdefault = False
+    cuboid.style.model3d.data = [
+        {
+            "backend": "generic",
+            "constructor": "Scatter3d",
+            "kwargs": {
+                "x": [-1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1],
+                "y": [-1, 1, 1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1],
+                "z": [-1, -1, -1, -1, -1, 1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1],
+                "mode": "lines",
+            },
+            "show": True,
+        },
+        {
+            "backend": "plotly",
+            "constructor": "Mesh3d",
+            "kwargs": {
+                "i": [7, 0, 0, 0, 4, 4, 2, 6, 4, 0, 3, 7],
+                "j": [0, 7, 1, 2, 6, 7, 1, 2, 5, 5, 2, 2],
+                "k": [3, 4, 2, 3, 5, 6, 5, 5, 0, 1, 7, 6],
+                "x": [-1, -1, 1, 1, -1, -1, 1, 1],
+                "y": [-1, 1, 1, -1, -1, 1, 1, -1],
+                "z": [-1, -1, -1, -1, 1, 1, 1, 1],
+                "facecolor": ["red"] * 12,
+            },
+            "show": True,
+        },
+    ]
+    fig = go.Figure()
+    cuboid.show(canvas=fig, style={"model3d_showdefault": True})
+
+    cuboid.style.model3d.data[0].show = False
+    cuboid.show(canvas=fig)
+
+    coll = magpy.Collection(cuboid)
+    coll.rotate_from_angax(45, "z")
+    magpy.show(
+        coll,
+        canvas=fig,
+        animation=True,
+        style={"model3d_showdefault": False},
+    )
+
+    def my_callable_kwargs():
+        return {
+            "x": [-1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1],
+            "y": [-1, 1, 1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1],
+            "z": [-1, -1, -1, -1, -1, 1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1],
+            "mode": "lines",
+        }
+
+    cuboid.style.model3d.add_trace(
+        backend="plotly", constructor="Scatter3d", kwargs=my_callable_kwargs, show=True
+    )
+    cuboid.style.model3d.data[0].show = False
+    cuboid.show(
+        canvas=fig,
+        style_path_show=False,
+        style={"model3d_showdefault": False},
+    )
+
+
+def test_CustomSource_display():
+    """testing display"""
+    fig = go.Figure()
+    cs = magpy.misc.CustomSource(style={"color": "blue"})
+    x = cs.show(canvas=fig, backend="plotly")
+    assert x is None, "display test fail"
+
+
+def test_empty_display():
+    """should not fail if nothing to display"""
+    fig = magpy.show(backend="plotly", return_fig=True)
+    assert isinstance(fig, go.Figure), "empty display plotly test fail"
+
+
+def test_display_warnings():
+    """should display some animation warnings"""
+    magpy.defaults.display.backend = "plotly"
+    magpy.defaults.display.animation.maxfps = 2
+    magpy.defaults.display.animation.maxframes = 2
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.4, 0.4, 0.4), (4, 4, 4), 10), start=-1)
+    fig = go.Figure()
+
+    with pytest.warns(UserWarning):  # animation_fps to big warning
+        src.show(canvas=fig, animation=5, animation_fps=3)
+    with pytest.warns(UserWarning):  # max frames surpassed
+        src.show(canvas=fig, animation=True, animation_time=2, animation_fps=1)
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    with pytest.warns(UserWarning):  # no object path detected
+        src.show(canvas=fig, style_path_frames=[], animation=True)
+
+
+def test_bad_animation_value():
+    """should fail if animation is not a boolean or a positive number"""
+    magpy.defaults.display.backend = "plotly"
+    magpy.defaults.display.animation.maxfps = 2
+    magpy.defaults.display.animation.maxframes = 2
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.4, 0.4, 0.4), (4, 4, 4), 10), start=-1)
+    fig = go.Figure()
+
+    with pytest.raises(MagpylibBadUserInput):
+        src.show(canvas=fig, animation=-1)
+
+
+def test_subplots():
+    """test subplots"""
+    sensors = magpy.Collection(
+        [
+            magpy.Sensor(
+                pixel=np.linspace((x, 0, -0.2), (x, 0, 0.2), 2), style_label=str(x)
+            )
+            for x in np.linspace(0, 10, 11)
+        ]
+    )
+    cyl1 = magpy.magnet.Cylinder(polarization=(0.1, 0, 0), dimension=(1, 2))
+
+    # define paths
+    sensors.position = np.linspace((0, 0, -3), (0, 0, 3), 100)
+    cyl1.position = (4, 0, 0)
+    cyl1.rotate_from_angax(angle=np.linspace(0, 300, 100), start=0, axis="z", anchor=0)
+    objs = cyl1, sensors
+
+    # with implicit axes
+    fig = go.Figure()
+    with magpy.show_context(
+        backend="plotly", canvas=fig, animation=False, sumup=False, pixel_agg="mean"
+    ) as s:
+        s.show(
+            *objs, col=1, output="B", style_path_frames=10, sumup=False, pixel_agg=None
+        )
+
+    # bad output value
+    with pytest.raises(
+        ValueError, match=r"The `output` parameter must start with 'B', 'H', 'M', 'J'.*"
+    ):
+        magpy.show(*objs, canvas=fig, output="bad_output")
+
+
+def test_legends():
+    """test legends"""
+    f = 0.5
+    N = 3
+    xs = f * np.array([-1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1])
+    ys = f * np.array([-1, 1, 1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1])
+    zs = f * np.array([-1, -1, -1, -1, -1, 1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1])
+    trace_plotly = {
+        "backend": "plotly",
+        "constructor": "scatter3d",
+        "kwargs": {"x": xs, "y": ys, "z": zs, "mode": "lines"},
+    }
+    c = magpy.magnet.Cuboid(
+        polarization=(0, 0, 1), dimension=(1, 1, 1), style_label="Plotly extra trace"
+    )
+    c.style.model3d.add_trace(trace_plotly)
+
+    fig = magpy.show(
+        c,
+        backend="plotly",
+        style_path_frames=2,
+        style_legend_show=False,
+        # style_model3d_showdefault=False,
+        return_fig=True,
+    )
+    assert [t.name for t in fig.data] == ["Plotly extra trace (1m|1m|1m)"] * 2
+    assert [t.showlegend for t in fig.data] == [False, False]
+
+    fig = magpy.show(
+        c,
+        backend="plotly",
+        style_path_frames=2,
+        # style_legend_show=False,
+        # style_model3d_showdefault=False,
+        return_fig=True,
+    )
+    assert [t.name for t in fig.data] == ["Plotly extra trace (1m|1m|1m)"] * 2
+    assert [t.showlegend for t in fig.data] == [True, False]
+
+    fig = magpy.show(
+        c,
+        backend="plotly",
+        style_path_frames=2,
+        # style_legend_show=False,
+        style_model3d_showdefault=False,
+        return_fig=True,
+    )
+    assert [t.name for t in fig.data] == ["Plotly extra trace (1m|1m|1m)"]
+    assert [t.showlegend for t in fig.data] == [True]
+
+    c.rotate_from_angax([10 * i for i in range(N)], "y", start=0, anchor=(0, 0, 10))
+    fig = magpy.show(
+        c,
+        backend="plotly",
+        style_path_frames=2,
+        # style_legend_show=False,
+        # style_model3d_showdefault=False,
+        return_fig=True,
+    )
+    assert [t.name for t in fig.data] == ["Plotly extra trace (1m|1m|1m)"] * 4
+    assert [t.showlegend for t in fig.data] == [True, False, False, False]
+
+    fig = magpy.show(
+        markers=[(0, 0, 0)],
+        backend="plotly",
+        style_legend_show=False,
+        return_fig=True,
+    )
+
+    assert [t.name for t in fig.data] == ["Marker"]
+    assert [t.showlegend for t in fig.data] == [False]
+
+
+def test_color_precedence():
+    """Test if color precedence is respected when calling in nested collections"""
+    c1 = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    c2 = c1.copy(position=(1, 0, 0))
+    c3 = c1.copy(position=(2, 0, 0))
+    coll = magpy.Collection(c1, magpy.Collection(c2, c3))
+    kw = {
+        "backend": "plotly",
+        "style_magnetization_show": False,
+        "colorsequence": ["red", "blue", "green"],
+        "return_fig": True,
+    }
+    fig = magpy.show(coll, **kw)
+    assert [tr["color"] for tr in fig.data] == ["red"]
+
+    fig = magpy.show(*coll, **kw)
+    assert [tr["color"] for tr in fig.data] == ["red", "blue"]
+
+    fig = magpy.show(*coll.sources_all, **kw)
+    assert [tr["color"] for tr in fig.data] == ["red", "blue", "green"]
+
+    fig = magpy.show({"objects": c1, "col": 1}, {"objects": c1, "col": 2}, **kw)
+    # sane obj in different subplot should have same color
+    assert [tr["color"] for tr in fig.data] == ["red", "red"]
+
+
+def test_colors_output2d():
+    """Tests if lines have objects corresponding colors in output=Bx, By..."""
+    l1 = magpy.current.Circle(
+        current=1,
+        diameter=1,
+        style_label="L1",
+        style_arrow_show=False,
+    )
+    l2 = l1.copy(diameter=2)
+    s1 = magpy.Sensor(
+        pixel=[[0, 0, 0], [0, 1, 0]],
+        position=np.linspace((-1, 0, 1), (1, 0, 1), 10),
+        style_label="S",
+        style_model3d_showdefault=False,
+    )
+    s2 = s1.copy().move((0, 0, 1))
+    objs = {"objects": [l1, l2, s1, s2]}
+    kw = {
+        "backend": "plotly",
+        "return_fig": True,
+        "colorsequence": ["red", "blue", "green", "cyan"],
+    }
+    kw2d = {"output": "Bx", "col": 2}
+
+    def get_scatters2d(fig):
+        return [t.line.color for t in fig.data if t.type == "scatter"]
+
+    fig = magpy.show(objs, {**objs, **kw2d, "sumup": True}, **kw)
+    assert get_scatters2d(fig) == ["green", "cyan"]
+
+    fig = magpy.show(objs, {**objs, **kw2d, "sumup": True, "pixel_agg": None}, **kw)
+    assert get_scatters2d(fig) == [*["green"] * 2, *["cyan"] * 2]
+
+    fig = magpy.show(objs, {**objs, **kw2d, "sumup": False}, **kw)
+    assert get_scatters2d(fig) == [*["red"] * 2, *["blue"] * 2]
+
+    fig = magpy.show(objs, {**objs, **kw2d, "sumup": False, "pixel_agg": None}, **kw)
+    assert get_scatters2d(fig) == [*["red"] * 4, *["blue"] * 4]
+
+
+def test_units_length():
+    """test units lengths"""
+
+    dims = (1, 2, 3)
+    c1 = magpy.magnet.Cuboid(dimension=dims, polarization=(1, 2, 3))
+    inputs = [
+        {"objects": c1, "row": 1, "col": 1, "units_length": "m", "zoom": 3},
+        {"objects": c1, "row": 1, "col": 2, "units_length": "dm", "zoom": 2},
+        {"objects": c1, "row": 2, "col": 1, "units_length": "cm", "zoom": 1},
+        {"objects": c1, "row": 2, "col": 2, "units_length": "mm", "zoom": 0},
+    ]
+    fig = magpy.show(
+        *inputs,
+        backend="plotly",
+        return_fig=True,
+    )
+    for ind, inp in enumerate(inputs):
+        scene = getattr(fig.layout, f"scene{'' if ind == 0 else ind + 1}")
+        for k in "xyz":
+            ax = getattr(scene, f"{k}axis")
+            assert ax.title.text == f"{k} ({inp['units_length']})"
+            factor = get_unit_factor(inp["units_length"], target_unit="m")
+            r = (inp["zoom"] + 1) / 2 * factor * max(dims)
+            assert ax.range == (-r, r)
diff --git a/tests/test_display_pyvista.py b/tests/test_display_pyvista.py
new file mode 100644
index 000000000..bbf9694fe
--- /dev/null
+++ b/tests/test_display_pyvista.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import contextlib
+import os
+import sys
+import tempfile
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+import pyvista as pv
+
+import magpylib as magpy
+
+HAS_IMAGEIO = True
+try:
+    import imageio
+except ModuleNotFoundError:
+    HAS_IMAGEIO = False
+
+# pylint: disable=no-member
+
+# pylint: disable=broad-exception-caught
+FFMPEG_FAILED = False
+try:
+    try:
+        import imageio_ffmpeg
+
+        imageio_ffmpeg.get_ffmpeg_exe()
+    except ImportError as err:
+        if HAS_IMAGEIO:
+            imageio.plugins.ffmpeg.download()
+        else:
+            raise err
+except Exception:
+    # skip test if ffmpeg cannot be loaded
+    FFMPEG_FAILED = True
+
+
+def test_Cuboid_display():
+    "test simple display with path"
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    src.move([[i, 0, 0] for i in range(2)], start=0)
+    fig = src.show(return_fig=True, style_path_numbering=True, backend="pyvista")
+    assert isinstance(fig, pv.Plotter)
+
+
+def test_extra_model3d():
+    """test extra model 3d"""
+    trace_mesh3d = {
+        "constructor": "Mesh3d",
+        "kwargs": {
+            "x": (1, 0, -1, 0),
+            "y": (-0.5, 1.2, -0.5, 0),
+            "z": (-0.5, -0.5, -0.5, 1),
+            "i": (0, 0, 0, 1),
+            "j": (1, 1, 2, 2),
+            "k": (2, 3, 3, 3),
+            "opacity": 0.5,
+            "facecolor": ["blue"] * 2 + ["red"] * 2,
+        },
+    }
+    coll = magpy.Collection(position=(0, -3, 0), style_label="'Mesh3d' trace")
+    coll.style.model3d.add_trace(trace_mesh3d)
+
+    magpy.show(coll, return_fig=True, backend="pyvista")
+
+
+def test_subplots():
+    """Test pyvista animation"""
+    # define sensor and source
+    magpy.defaults.reset()
+    sensor = magpy.Sensor(
+        pixel=np.linspace((0, 0, -0.2), (0, 0, 0.2), 2), style_size=1.5
+    )
+    sensor.style.label = "Sensor1"
+    cyl1 = magpy.magnet.Cylinder(
+        polarization=(0.1, 0, 0), dimension=(1, 2), style_label="Cylinder1"
+    )
+
+    # define paths
+    N = 2
+    sensor.position = np.linspace((0, 0, -3), (0, 0, 3), N)
+    cyl1.position = (4, 0, 0)
+    cyl1.rotate_from_angax(angle=np.linspace(0, 300, N), start=0, axis="z", anchor=0)
+    cyl2 = cyl1.copy().move((0, 0, 5))
+    objs = cyl1, cyl2, sensor
+
+    magpy.show(
+        {"objects": objs, "col": 1, "output": ("Bx", "By", "Bz")},
+        {"objects": objs, "col": 2},
+        backend="pyvista",
+        sumup=True,
+        return_fig=True,
+    )
+
+
+def test_animation_warning():
+    "animation not supported, should warn and display static"
+    pl = pv.Plotter()
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    with pytest.warns(UserWarning):
+        src.show(canvas=pl, animation=True, backend="pyvista")
+
+
+@pytest.mark.parametrize("is_notebook_result", [True, False])
+@pytest.mark.parametrize("extension", ["mp4", "gif"])
+@pytest.mark.parametrize("filename", [True, False])
+def test_pyvista_animation(is_notebook_result, extension, filename):
+    """Test pyvista animation"""
+    # define sensor and source
+    pv.OFF_SCREEN = True
+    if sys.platform == "linux":
+        os.environ["PYVISTA_VTK_OSMESA"] = "1"
+    if not HAS_IMAGEIO and extension == "gif":
+        pytest.skip("Extension gif skipped because imageio failed to load")
+    if FFMPEG_FAILED and extension == "mp4":
+        pytest.skip("Extension mp4 skipped because ffmpeg failed to load")
+    sens = magpy.Sensor()
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    src.move([[0, 0, 0], [0, 0, 1]], start=0)
+    objs = [src, sens]
+
+    with (
+        patch("magpylib._src.utility.is_notebook", return_value=is_notebook_result),
+        patch("webbrowser.open"),
+    ):
+        try:
+            from pathlib import Path
+
+            temp = Path(tempfile.gettempdir()) / os.urandom(24).hex()
+            temp = temp.with_suffix(f".{extension}")
+            animation_output = temp if filename else extension
+            magpy.show(
+                {"objects": objs, "col": 1, "output": ("Bx", "By", "Bz")},
+                {"objects": objs, "col": 2},
+                backend="pyvista",
+                animation=True,
+                animation_output=animation_output,
+                mp4_quality=1,
+                return_fig=True,
+            )
+        finally:
+            with contextlib.suppress(FileNotFoundError):
+                temp.unlink()
diff --git a/tests/test_display_utility.py b/tests/test_display_utility.py
new file mode 100644
index 000000000..52c781f77
--- /dev/null
+++ b/tests/test_display_utility.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import matplotlib.pyplot as plt
+import numpy as np
+import plotly
+import pytest
+import pyvista
+
+import magpylib as magpy
+from magpylib._src.display.traces_utility import (
+    draw_arrow_from_vertices,
+    merge_scatter3d,
+)
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+
+def test_draw_arrow_from_vertices():
+    """tests also the edge case when a vertex is in -y direction"""
+    vertices = np.array(
+        [
+            [-1.0, 1.0, 1.0],
+            [-1.0, -1.0, 1.0],
+            [-1.0, -1.0, -1.0],
+            [-1.0, 1.0, -1.0],
+            [-1.0, 1.0, 1.0],
+        ]
+    )
+    result = draw_arrow_from_vertices(vertices, sign=1, arrow_size=1)
+    expected = np.array(
+        [
+            [-1.0, 1.0, 1.0],
+            [-1.0, 0.0, 1.0],
+            [-0.88, 0.2, 1.0],
+            [-1.0, 0.0, 1.0],
+            [-1.12, 0.2, 1.0],
+            [-1.0, 0.0, 1.0],
+            [-1.0, -1.0, 1.0],
+            [-1.0, -1.0, 1.0],
+            [-1.0, -1.0, 0.0],
+            [-1.12, -1.0, 0.2],
+            [-1.0, -1.0, 0.0],
+            [-0.88, -1.0, 0.2],
+            [-1.0, -1.0, 0.0],
+            [-1.0, -1.0, -1.0],
+            [-1.0, -1.0, -1.0],
+            [-1.0, 0.0, -1.0],
+            [-1.12, -0.2, -1.0],
+            [-1.0, 0.0, -1.0],
+            [-0.88, -0.2, -1.0],
+            [-1.0, 0.0, -1.0],
+            [-1.0, 1.0, -1.0],
+            [-1.0, 1.0, -1.0],
+            [-1.0, 1.0, 0.0],
+            [-1.12, 1.0, -0.2],
+            [-1.0, 1.0, 0.0],
+            [-0.88, 1.0, -0.2],
+            [-1.0, 1.0, 0.0],
+            [-1.0, 1.0, 1.0],
+        ]
+    )
+
+    np.testing.assert_allclose(
+        result, expected, err_msg="draw arrow from vertices failed"
+    )
+
+
+def test_bad_backend():
+    """test bad plotting input name"""
+    c = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    with pytest.raises(MagpylibBadUserInput):
+        c.show(backend="asdf")
+
+
+@pytest.mark.parametrize(
+    ("canvas", "is_notebook_result", "backend"),
+    [
+        (None, True, "plotly"),
+        (None, False, "matplotlib"),
+        (plt.subplot(projection="3d"), True, "matplotlib"),
+        (plt.subplot(projection="3d"), False, "matplotlib"),
+        (plotly.graph_objects.Figure(), True, "plotly"),
+        (plotly.graph_objects.Figure(), False, "plotly"),
+        (plotly.graph_objects.FigureWidget(), True, "plotly"),
+        (plotly.graph_objects.FigureWidget(), False, "plotly"),
+        (pyvista.Plotter(), True, "pyvista"),
+        (pyvista.Plotter(), False, "pyvista"),
+    ],
+)
+def test_infer_backend(canvas, is_notebook_result, backend):
+    """test inferring auto backend"""
+    with patch("magpylib._src.utility.is_notebook", return_value=is_notebook_result):
+        # pylint: disable=import-outside-toplevel
+        from magpylib._src.display.display import infer_backend
+
+        assert infer_backend(canvas) == backend
+
+
+def test_merge_scatter3d():
+    """test_merge_scatter3d"""
+
+    def get_traces(n):
+        return [{"type": "scatter3d", "x": [i], "y": [i], "z": [i]} for i in range(n)]
+
+    merge_scatter3d(*get_traces(1))
+    merge_scatter3d(*get_traces(3))
diff --git a/tests/test_elliptics.py b/tests/test_elliptics.py
new file mode 100644
index 000000000..2a780264b
--- /dev/null
+++ b/tests/test_elliptics.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from magpylib._src.fields.special_cel import cel, cel0, celv
+from magpylib._src.fields.special_el3 import el3, el3_angle, el3v, el30
+
+
+def test_except_cel0():
+    """bad cel0 input"""
+    with pytest.raises(RuntimeError):
+        cel0(0, 0.1, 0.2, 0.3)
+
+
+def test_except_el30():
+    """bad el3"""
+    with pytest.raises(RuntimeError):
+        el30(1, 1, -1)
+
+
+def test_el3_inputs():
+    """xxx"""
+    assert el30(0.1, 0.1, 1) == 0.09983241728793554
+    assert el30(1, 0.5, 0.25) == 1.0155300257327204
+
+
+def test_el3_vs_original():
+    """
+    test new and vectroized el3 implemtnation vs original one
+    """
+    # store computations from original implementation
+    # from florian_ell3_paper import el3 as el30
+    # N = 10000
+    # x11 = np.random.rand(N)*5
+    # kc11 = (np.random.rand(N)-.5)*10
+    # p11 = (np.random.rand(N)-.5)*10
+    # result0 = np.array([el30(x, kc, p) for x,kc,p in zip(x11,kc11,p11)])
+    # np.save('data_test_el3', np.array([result0,x11,kc11,p11]))
+
+    # load data from original implementation
+    data = np.load("tests/testdata/testdata_el3.npy")
+    res0, x11, kc11, p11 = data
+
+    # compare to vectorized
+    resv = el3v(x11, kc11, p11)
+    np.testing.assert_allclose(res0, resv)
+
+    # compare to modified original
+    res1 = np.array([el30(x, kc, p) for x, kc, p in zip(x11, kc11, p11, strict=False)])
+    np.testing.assert_allclose(res0, res1)
+
+
+def test_el3_angle_vs_original():
+    """
+    test vectroized el3_angle implemtnation vs original one
+    """
+    # # store computations from original implementation of el3_angle
+    # N = 1000
+    # phis = np.random.rand(N) * np.pi/2
+    # ms = (np.random.rand(N)-.9)*10
+    # ns = (np.random.rand(N)-.5)*5
+    # result0 = np.array([el3_angle0(phi, n, m) for phi,n,m in zip(phis,ns,ms)])
+    # np.save('data_test_el3_angle', np.array([result0,phis,ns,ms]))
+
+    # load data from original implementation
+    data = np.load("tests/testdata/testdata_el3_angle.npy")
+    res0, phis, ns, ms = data
+
+    # compare to vectorized
+    resv = el3_angle(phis, ns, ms)
+    np.testing.assert_allclose(res0, resv)
+
+
+def test_el3s():
+    """
+    test el30, el3v, el3 vs each other
+    """
+    N = 999
+    rng = np.random.default_rng()
+    xs = (rng.random(N)) * 5
+    kcs = (rng.random(N) - 0.5) * 10
+    ps = (rng.random(N) - 0.5) * 10
+
+    res0 = [el30(x, kc, p) for x, kc, p in zip(xs, kcs, ps, strict=False)]
+    res1 = el3v(xs, kcs, ps)
+    res2 = el3(xs, kcs, ps)
+
+    np.testing.assert_allclose(res0, res1)
+    np.testing.assert_allclose(res1, res2)
+
+
+def test_cels():
+    """
+    test cel, cel0 (from florian) vs celv (from magpylib original)
+    against each other
+    """
+    N = 999
+    rng = np.random.default_rng()
+    kcc = (rng.random(N) - 0.5) * 10
+    pp = (rng.random(N) - 0.5) * 10
+    cc = (rng.random(N) - 0.5) * 10
+    ss = (rng.random(N) - 0.5) * 10
+
+    res0 = [cel0(kc, p, c, s) for kc, p, c, s in zip(kcc, pp, cc, ss, strict=False)]
+    res1 = celv(kcc, pp, cc, ss)
+    res2 = cel(kcc, pp, cc, ss)
+
+    np.testing.assert_allclose(res0, res1)
+    np.testing.assert_allclose(res1, res2)
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 000000000..e64b942df
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.fields.field_wrap_BH import getBH_level2
+from magpylib._src.input_checks import check_format_input_observers
+from magpylib._src.utility import check_path_format, format_obj_input, format_src_inputs
+
+GETBH_KWARGS = {
+    "sumup": False,
+    "squeeze": True,
+    "pixel_agg": None,
+    "output": "ndarray",
+    "in_out": "auto",
+}
+
+
+def getBHv_unknown_source_type():
+    """unknown source type"""
+    getBH_level2(
+        sources="badName",
+        observers=(0, 0, 0),
+        polarization=(1, 0, 0),
+        dimension=(0, 2, 1, 0, 360),
+        position=(0, 0, -0.5),
+        field="B",
+        **GETBH_KWARGS,
+    )
+
+
+def getBH_level2_bad_input1():
+    """test BadUserInput error at getBH_level2"""
+    src = magpy.magnet.Cuboid(polarization=(1, 1, 2), dimension=(1, 1, 1))
+    sens = magpy.Sensor()
+    getBH_level2(
+        [src, sens],
+        (0, 0, 0),
+        sumup=False,
+        squeeze=True,
+        pixel_agg=None,
+        in_out="auto",
+        field="B",
+        output="ndarray",
+    )
+
+
+def getBH_different_pixel_shapes():
+    """different pixel shapes"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    sens1 = magpy.Sensor()
+    sens2 = magpy.Sensor(pixel=[(0, 0, 0), (0, 0, 1), (0, 0, 2)])
+    magpy.getB(pm1, [sens1, sens2])
+
+
+# getBHv missing inputs ------------------------------------------------------
+def getBHv_missing_input1():
+    """missing field"""
+    x = np.array([(1, 2, 3)])
+    # pylint: disable=missing-kwoa
+    getBH_level2(
+        sources="Cuboid", observers=x, polarization=x, dimension=x, **GETBH_KWARGS
+    )
+
+
+def getBHv_missing_input2():
+    """missing source_type"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(observers=x, field="B", polarization=x, dimension=x, **GETBH_KWARGS)
+
+
+def getBHv_missing_input3():
+    """missing observer"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(
+        sources="Cuboid", field="B", polarization=x, dimension=x, **GETBH_KWARGS
+    )
+
+
+def getBHv_missing_input4_cuboid():
+    """missing Cuboid mag"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(sources="Cuboid", observers=x, field="B", dimension=x, **GETBH_KWARGS)
+
+
+def getBHv_missing_input5_cuboid():
+    """missing Cuboid dim"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(
+        sources="Cuboid", observers=x, field="B", polarization=x, **GETBH_KWARGS
+    )
+
+
+def getBHv_missing_input4_cyl():
+    """missing Cylinder mag"""
+    x = np.array([(1, 2, 3)])
+    y = np.array([(1, 2)])
+    getBH_level2(
+        sources="Cylinder", observers=x, field="B", dimension=y, **GETBH_KWARGS
+    )
+
+
+def getBHv_missing_input5_cyl():
+    """missing Cylinder dim"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(
+        sources="Cylinder", observers=x, field="B", polarization=x, **GETBH_KWARGS
+    )
+
+
+def getBHv_missing_input4_sphere():
+    """missing Sphere mag"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(sources="Sphere", observers=x, field="B", dimension=1, **GETBH_KWARGS)
+
+
+def getBHv_missing_input5_sphere():
+    """missing Sphere dim"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(
+        sources="Sphere", observers=x, field="B", polarization=x, **GETBH_KWARGS
+    )
+
+
+# bad inputs -------------------------------------------------------------------
+def getBHv_bad_input1():
+    """different input lengths"""
+    x = np.array([(1, 2, 3)] * 3)
+    x2 = np.array([(1, 2, 3)] * 2)
+    getBH_level2(
+        sources="Cuboid",
+        observers=x,
+        field="B",
+        polarization=x2,
+        dimension=x,
+        **GETBH_KWARGS,
+    )
+
+
+def getBHv_bad_input2():
+    """bad source_type string"""
+    x = np.array([(1, 2, 3)])
+    getBH_level2(
+        sources="Cubooid",
+        observers=x,
+        field="B",
+        polarization=x,
+        dimension=x,
+        **GETBH_KWARGS,
+    )
+
+
+def getBHv_bad_input3():
+    """mixed input"""
+    x = np.array([(1, 2, 3)])
+    s = magpy.Sensor()
+    getBH_level2(
+        sources="Cuboid",
+        observers=s,
+        field="B",
+        polarization=x,
+        dimension=x,
+        **GETBH_KWARGS,
+    )
+
+
+def utility_format_obj_input():
+    """bad input object"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    format_obj_input([pm1, pm2, 333])
+
+
+def utility_format_src_inputs():
+    """bad src input"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    format_src_inputs([pm1, pm2, 1])
+
+
+def utility_format_obs_inputs():
+    """bad src input"""
+    sens1 = magpy.Sensor()
+    sens2 = magpy.Sensor()
+    possis = [1, 2, 3]
+    check_format_input_observers([sens1, sens2, possis, "whatever"])
+
+
+def utility_check_path_format():
+    """bad path format input"""
+    # pylint: disable=protected-access
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm1._position = [(1, 2, 3), (1, 2, 3)]
+    check_path_format(pm1)
+
+
+###############################################################################
+# BAD INPUT SHAPE EXCEPTIONS
+def bad_input_shape_basegeo_pos():
+    """bad position input shape"""
+    vec3 = (1, 2, 3)
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Cuboid(vec3, vec3, vec4)
+
+
+def bad_input_shape_cuboid_dim():
+    """bad cuboid dimension shape"""
+    vec3 = (1, 2, 3)
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Cuboid(vec3, vec4)
+
+
+def bad_input_shape_cuboid_mag():
+    """bad cuboid polarization shape"""
+    vec3 = (1, 2, 3)
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Cuboid(vec4, vec3)
+
+
+def bad_input_shape_cyl_dim():
+    """bad cylinder dimension shape"""
+    vec3 = (1, 2, 3)
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Cylinder(vec3, vec4)
+
+
+def bad_input_shape_cyl_mag():
+    """bad cylinder polarization shape"""
+    vec3 = (1, 2, 3)
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Cylinder(vec4, vec3)
+
+
+def bad_input_shape_sphere_mag():
+    """bad sphere polarization shape"""
+    vec4 = (1, 2, 3, 4)
+    magpy.magnet.Sphere(vec4, 1)
+
+
+def bad_input_shape_sensor_pix_pos():
+    """bad sensor pix_pos input shape"""
+    vec4 = (1, 2, 3, 4)
+    vec3 = (1, 2, 3)
+    magpy.Sensor(vec3, vec4)
+
+
+def bad_input_shape_dipole_mom():
+    """bad sphere polarization shape"""
+    vec4 = (1, 2, 3, 4)
+    magpy.misc.Dipole(moment=vec4)
+
+
+#####################################################################
+def test_except_utility():
+    """utility"""
+    with pytest.raises(MagpylibBadUserInput):
+        utility_check_path_format()
+    with pytest.raises(MagpylibBadUserInput):
+        utility_format_obj_input()
+    with pytest.raises(MagpylibBadUserInput):
+        utility_format_src_inputs()
+    with pytest.raises(MagpylibBadUserInput):
+        utility_format_obs_inputs()
+
+
+def test_except_getBHv():
+    """getBHv"""
+    with pytest.raises(TypeError):
+        getBHv_missing_input1()
+    with pytest.raises(TypeError):
+        getBHv_missing_input2()
+    with pytest.raises(TypeError):
+        getBHv_missing_input3()
+    with pytest.raises(TypeError):
+        getBHv_missing_input4_cuboid()
+    with pytest.raises(TypeError):
+        getBHv_missing_input4_cyl()
+    with pytest.raises(TypeError):
+        getBHv_missing_input4_sphere()
+    with pytest.raises(TypeError):
+        getBHv_missing_input5_cuboid()
+    with pytest.raises(TypeError):
+        getBHv_missing_input5_cyl()
+    with pytest.raises(TypeError):
+        getBHv_missing_input5_sphere()
+    with pytest.raises(MagpylibBadUserInput):
+        getBHv_bad_input1()
+    with pytest.raises(MagpylibBadUserInput):
+        getBHv_bad_input2()
+    with pytest.raises(MagpylibBadUserInput):
+        getBHv_bad_input3()
+    with pytest.raises(MagpylibBadUserInput):
+        getBHv_unknown_source_type()
+
+
+def test_except_getBH_lev2():
+    """getBH_level2 exception testing"""
+    with pytest.raises(MagpylibBadUserInput):
+        getBH_level2_bad_input1()
+    with pytest.raises(MagpylibBadUserInput):
+        getBH_different_pixel_shapes()
+
+
+def test_except_bad_input_shape_basegeo():
+    """BaseGeo bad input shapes"""
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_basegeo_pos()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_cuboid_dim()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_cuboid_mag()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_cyl_dim()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_cyl_mag()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_sphere_mag()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_sensor_pix_pos()
+    with pytest.raises(MagpylibBadUserInput):
+        bad_input_shape_dipole_mom()
diff --git a/tests/test_field_cylinder.py b/tests/test_field_cylinder.py
new file mode 100644
index 000000000..ba1c8262c
--- /dev/null
+++ b/tests/test_field_cylinder.py
@@ -0,0 +1,518 @@
+"""
+Testing all cases against a large set of pre-computed values
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.fields.field_BH_cylinder import BHJM_magnet_cylinder
+from magpylib._src.fields.field_BH_cylinder_segment import (
+    BHJM_cylinder_segment,
+    magnet_cylinder_segment_Hfield,
+)
+
+# pylint: disable="pointless-string-statement"
+# creating test data
+""" import os
+import numpy as np
+from magpylib._src.fields.field_BH_cylinder_tile import magnet_cylinder_segment_Hfield
+
+N = 1111
+null = np.zeros(N)
+R = np.random.rand(N) * 10
+R1, R2 = np.random.rand(2, N) * 5
+R2 = R1 + R2
+PHI, PHI1, PHI2 = (np.random.rand(3, N) - 0.5) * 10
+PHI2 = PHI1 + PHI2
+Z, Z1, Z2 = (np.random.rand(3, N) - 0.5) * 10
+Z2 = Z1 + Z2
+
+DIM_CYLSEG = np.array([R1, R2, PHI1, PHI2, Z1, Z2])
+POS_OBS = np.array([R, PHI, Z])
+MAG = np.random.rand(N, 3)
+
+DATA = {}
+
+# cases [112, 212, 132, 232]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z1 = z
+phi1 = phi
+r = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(
+    magnetizations=MAG,
+    dimensions=dim,
+    observers=obs_pos,
+)
+DATA["cases [112, 212, 132, 232]"] = {
+    "inputs": {"magnetizations": MAG, "dimensions": dim, "observers": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [122, 222, 132, 232]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z1 = z
+phi1 = phi + np.pi
+r = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [122, 222, 132, 232]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [113, 213, 133, 233, 115, 215, 135, 235]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z1 = z
+phi1 = phi
+r1 = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [113, 213, 133, 233, 115, 215, 135, 235]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+
+# cases [123, 223, 133, 233, 125, 225, 135, 235]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z1 = z
+phi1 = phi + np.pi
+r1 = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [123, 223, 133, 233, 125, 225, 135, 235]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [125, 225, 135, 235, 124, 224, 134, 234]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z1 = z
+phi1 = phi + np.pi
+r = r2
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [125, 225, 135, 235, 124, 224, 134, 234]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [211, 221, 212, 222]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+phi1 = phi
+phi2 = phi + np.pi
+r = null
+r1 = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [211, 221, 212, 222]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [214, 224, 215, 225]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+phi1 = phi
+phi2 = phi + np.pi
+r = r1
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [214, 224, 215, 225]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [111, 211, 121, 221, 112, 212, 122, 222]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z = z1
+phi1 = phi
+phi2 = phi + np.pi
+r = null
+r1 = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [111, 211, 121, 221, 112, 212, 122, 222]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [111, 211, 131, 231, 112, 212, 132, 232]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z = z1
+phi1 = phi
+r = null
+r1 = null
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [111, 211, 131, 231, 112, 212, 132, 232]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+# cases [115, 215, 135, 235, 114, 214, 134, 234]
+r1, r2, phi1, phi2, z1, z2 = DIM_CYLSEG
+r, phi, z = POS_OBS
+z = z1
+phi1 = phi
+r = r2
+obs_pos = np.array([r, phi, z]).T
+dim = np.array([r1, r2, phi1, phi2, z1, z2]).T
+H1 = magnet_cylinder_segment_Hfield(mag=MAG, dim=dim, obs_pos=obs_pos)
+DATA["cases [115, 215, 135, 235, 114, 214, 134, 234]"] = {
+    "inputs": {"mag": MAG, "dim": dim, "obs_pos": obs_pos},
+    "H_expected": H1,
+}
+
+folder = r"magpylib"
+
+np.save(os.path.join(folder, "tests/testdata/testdata_cy_cases"), DATA) """
+
+
+# data is actually pickled, and a dictionary is stored inside of a numpy array
+DATA = np.load("tests/testdata/testdata_cy_cases.npy", allow_pickle=True).item()
+
+
+@pytest.mark.parametrize(
+    ("inputs", "H_expected"),
+    [[v["inputs"], v["H_expected"]] for v in DATA.values()],
+    ids=list(DATA.keys()),
+)
+def test_cylinder_tile_slanovc(inputs, H_expected):
+    "testing precomputed cylinder test cases"
+    inputs_mod = {
+        "magnetizations": inputs["mag"],
+        "observers": inputs["obs_pos"],
+        "dimensions": inputs["dim"],
+    }
+    H = magnet_cylinder_segment_Hfield(**inputs_mod)  # factors come from B <->H change
+    np.testing.assert_allclose(H, H_expected)
+
+
+def test_cylinder_field1():
+    """test the new cylinder field against old, full-cylinder
+    implementations
+    """
+    N = 100
+    magg, dim, poso, B0 = np.load("tests/testdata/testdata_full_cyl.npy")
+
+    null = np.zeros(N)
+    eins = np.ones(N)
+    d, h, _ = dim.T  # pylint: disable=no-member
+    dim5 = np.array([null, d / 2, h, null, eins * 360]).T
+    B1 = BHJM_cylinder_segment(
+        field="B", observers=poso, polarization=magg, dimension=dim5
+    )
+
+    np.testing.assert_allclose(B1, B0)
+
+
+def test_cylinder_slanovc_field2():
+    """testing B for all input combinations in/out/surface of Tile solution"""
+    src = magpy.magnet.CylinderSegment(
+        polarization=(22, 33, 44), dimension=(0.5, 1, 2, 0, 90)
+    )
+
+    r_in = (0.5, 0.6, 0.3)
+    r_out = (1, 2, 3)
+    r_corn = (1, 0, 0)
+
+    b_in = (5.52525937, 13.04561569, 40.11111556)
+    b_out = (0.0177018, 0.1277188, 0.27323195)
+    b_corn = (0, 0, 0)
+
+    # only inside
+    btest = np.array([b_in] * 3)
+    B = src.getB([r_in] * 3)
+    np.testing.assert_allclose(B, btest)
+
+    # only edge
+    btest = np.array([b_corn] * 3)
+    B = src.getB([r_corn] * 3)
+    np.testing.assert_allclose(B, btest)
+
+    # only outside
+    btest = np.array([b_out] * 3)
+    B = src.getB([r_out] * 3)
+    np.testing.assert_allclose(B, btest, rtol=1e-05, atol=1e-08)
+
+    # edge + out
+    btest = np.array([b_corn, b_corn, b_out])
+    B = src.getB([r_corn, r_corn, r_out])
+    np.testing.assert_allclose(B, btest, rtol=1e-05, atol=1e-08)
+
+    # surf + in
+    btest = np.array([b_corn, b_corn, b_in])
+    B = src.getB(r_corn, r_corn, r_in)
+    np.testing.assert_allclose(B, btest)
+
+    # in + out
+    btest = np.array([b_out, b_in])
+    B = src.getB(r_out, r_in)
+    np.testing.assert_allclose(B, btest, rtol=1e-05, atol=1e-08)
+
+    # in + out + surf
+    btest = np.array([b_corn, b_corn, b_in, b_out, b_corn, b_corn])
+    B = src.getB([r_corn, r_corn, r_in, r_out, r_corn, r_corn])
+    np.testing.assert_allclose(B, btest, rtol=1e-05, atol=1e-08)
+
+
+def test_cylinder_slanovc_field3():
+    """testing H for all input combinations in/out/surface of Tile solution"""
+    src = magpy.magnet.CylinderSegment(
+        polarization=(22, 33, 44), dimension=(0.5, 1, 2, 0, 90)
+    )
+
+    hinn = np.array((-13.11018204, -15.87919449, -3.09467591)) * 1e6
+    hout = np.array((0.01408664, 0.1016354, 0.21743108)) * 1e6
+    null = (0, 0, 0)
+
+    # only inside
+    htest = np.array([hinn] * 3)
+    H = src.getH([[0.5, 0.6, 0.3]] * 3)
+    np.testing.assert_allclose(H, htest)
+
+    # only surf
+    htest = np.array([null] * 3)
+    H = src.getH([[1, 0, 0]] * 3)
+    np.testing.assert_allclose(H, htest)
+
+    # only outside
+    htest = np.array([hout] * 3)
+    H = src.getH([[1, 2, 3]] * 3)
+    np.testing.assert_allclose(H, htest)
+
+    # surf + out
+    htest = np.array([null, null, hout])
+    H = src.getH([0.6, 0, 1], [1, 0, 0.5], [1, 2, 3])
+    np.testing.assert_allclose(H, htest)
+
+    # surf + in
+    htest = np.array([null, null, hinn])
+    H = src.getH([0, 0.5, 1], [1, 0, 0.5], [0.5, 0.6, 0.3])
+    np.testing.assert_allclose(H, htest)
+
+    # in + out
+    htest = np.array([hout, hinn])
+    H = src.getH([1, 2, 3], [0.5, 0.6, 0.3])
+    np.testing.assert_allclose(H, htest)
+
+    # in + out + surf
+    htest = np.array([null, null, hinn, hout, null, null])
+    H = src.getH(
+        [0.5, 0.5, 1],
+        [0, 1, 0.5],
+        [0.5, 0.6, 0.3],
+        [1, 2, 3],
+        [0.5, 0.6, -1],
+        [0, 1, -0.3],
+    )
+    np.testing.assert_allclose(H, htest)
+
+
+def test_cylinder_rauber_field4():
+    """
+    test continuity across indefinite form in cylinder_rauber field when observer at r=r0
+    """
+    src = magpy.magnet.Cylinder(polarization=(22, 33, 0), dimension=(2, 2))
+    es = list(10 ** -np.linspace(11, 15, 50))
+    xs = np.r_[1 - np.array(es), 1, 1 + np.array(es)[::-1]]
+    possis = [(x, 0, 1.5) for x in xs]
+    B = src.getB(possis)
+    B = B / B[25]
+    assert np.all(abs(1 - B) < 1e-8)
+
+
+def test_cylinder_tile_negative_phi():
+    """same result for phi>0 and phi<0 inputs"""
+    src1 = magpy.magnet.CylinderSegment(
+        polarization=(11, 22, 33), dimension=(2, 4, 4, 0, 45)
+    )
+    src2 = magpy.magnet.CylinderSegment(
+        polarization=(11, 22, 33), dimension=(2, 4, 4, -360, -315)
+    )
+    B1 = src1.getB((1, 0.5, 0.1))
+    B2 = src2.getB((1, 0.5, 0.1))
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_cylinder_tile_vs_fem():
+    """test against fem results"""
+    fd1, fd2, fd3, fd4 = np.load("tests/testdata/testdata_femDat_cylinder_tile2.npy")
+
+    # chosen magnetization vectors
+    mag1 = np.array((1, -1, 0)) / np.sqrt(2) * 1000
+    mag2 = np.array((0, 0, 1)) * 1000
+    mag3 = np.array((0, 1, -1)) / np.sqrt(2) * 1000
+
+    # Magpylib magnet collection
+    m1 = magpy.magnet.CylinderSegment(
+        polarization=mag1,
+        dimension=(1, 2, 1, -90, 0),
+    )
+    m2 = magpy.magnet.CylinderSegment(
+        polarization=mag2,
+        dimension=(1, 2.5, 1.5, 200, 250),
+    )
+    m3 = magpy.magnet.CylinderSegment(
+        polarization=mag3,
+        dimension=(0.75, 3, 0.5, 70, 180),
+    )
+    col = m1 + m2 + m3
+
+    # create observer circles (see FEM screen shot)
+    n = 101
+    ts = np.linspace(0, 359.999, n) * np.pi / 180
+    poso1 = np.array([0.5 * np.cos(ts), 0.5 * np.sin(ts), np.zeros(n)]).T
+    poso2 = np.array([1.5 * np.cos(ts), 1.5 * np.sin(ts), np.zeros(n)]).T
+    poso3 = np.array([1.5 * np.cos(ts), 1.5 * np.sin(ts), np.ones(n)]).T
+    poso4 = np.array([3.5 * np.cos(ts), 3.5 * np.sin(ts), np.zeros(n)]).T
+
+    # compute and plot fields
+    B1 = col.getB(poso1)
+    B2 = col.getB(poso2)
+    B3 = col.getB(poso3)
+    B4 = col.getB(poso4)
+
+    amp1 = np.linalg.norm(B1, axis=1)
+    amp2 = np.linalg.norm(B2, axis=1)
+    amp3 = np.linalg.norm(B3, axis=1)
+    amp4 = np.linalg.norm(B4, axis=1)
+
+    # pylint: disable=unsubscriptable-object
+    assert np.amax((fd1[:, 1:] * 1000 - B1).T / amp1) < 0.05
+    assert np.amax((fd2[5:-5, 1:] * 1000 - B2[5:-5]).T / amp2[5:-5]) < 0.05
+    assert np.amax((fd3[:, 1:] * 1000 - B3).T / amp3) < 0.05
+    assert np.amax((fd4[:, 1:] * 1000 - B4).T / amp4) < 0.05
+
+
+def test_cylinder_corner():
+    """test corner =0 behavior"""
+    a = 1
+    s = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    B = s.getB(
+        [
+            [0, a, a],
+            [0, a, -a],
+            [0, -a, -a],
+            [0, -a, a],
+            [a, 0, a],
+            [a, 0, -a],
+            [-a, 0, -a],
+            [-a, 0, a],
+        ]
+    )
+    np.testing.assert_allclose(B, np.zeros((8, 3)))
+
+
+def test_cylinder_corner_scaling():
+    """test corner=0 scaling"""
+    a = 1
+    obs = [[a, 0, a + 1e-14], [a + 1e-14, 0, a]]
+    s = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    Btest = [
+        [5.12553286e03, -2.26623480e00, 2.59910242e02],
+        [5.12803286e03, -2.26623480e00, 9.91024238e00],
+    ]
+    np.testing.assert_allclose(s.getB(obs), Btest)
+
+    a = 1000
+    obs = [[a, 0, a + 1e-14], [a + 1e-14, 0, a]]
+    s = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    np.testing.assert_allclose(s.getB(obs), np.zeros((2, 3)))
+
+
+def test_cylinder_scaling_invariance():
+    """test scaling invariance"""
+    obs = np.array(
+        [
+            [-0.12788963, 0.14872334, -0.35838915],
+            [-0.17319799, 0.39177646, 0.22413971],
+            [-0.15831916, -0.39768996, 0.41800279],
+            [-0.05762575, 0.19985373, 0.02645361],
+            [0.19120126, -0.13021813, -0.21615004],
+            [0.39272212, 0.36457661, -0.09758084],
+            [-0.39270581, -0.19805643, 0.36988649],
+            [0.28942161, 0.31003054, -0.29558298],
+            [0.13083584, 0.31396182, -0.11231319],
+            [-0.04097917, 0.43394138, -0.14109254],
+        ]
+    )
+
+    a = 1e-6
+    s1 = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    Btest1 = s1.getB(obs * a)
+
+    a = 1
+    s2 = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    Btest2 = s2.getB(obs)
+
+    a = 1e7
+    s3 = magpy.magnet.Cylinder(polarization=(10, 10, 1000), dimension=(2 * a, 2 * a))
+    Btest3 = s3.getB(obs * a)
+
+    np.testing.assert_allclose(Btest1, Btest2)
+    np.testing.assert_allclose(Btest1, Btest3)
+
+
+def test_cylinder_diametral_small_r():
+    """
+    test if the transition from Taylor series to general case is smooth
+    test if the general case fluctuations are small
+    """
+    B = BHJM_magnet_cylinder(
+        field="B",
+        observers=np.array([(x, 0, 3) for x in np.logspace(-1.4, -1.2, 1000)]),
+        polarization=np.array([(1, 1, 0)] * 1000),
+        dimension=np.array([(2, 2)] * 1000),
+    )
+
+    dB = np.log(abs(B[1:] - B[:-1]))
+    ddB = abs(dB[1:] - dB[:-1])
+    ddB = abs(ddB - np.mean(ddB, axis=0))
+
+    assert np.all(ddB < 0.001)
+
+
+def test_cyl_vs_cylseg_axial_H_inside_mask():
+    """see https://github.com/magpylib/magpylib/issues/703"""
+    field = "H"
+    obs = np.array([(0.1, 0.2, 0.3)])
+    pols = np.array([(0, 0, 1)])
+    dims = np.array([(1, 1)])
+    dims_cs = np.array([(0, 0.5, 1, 0, 360)])
+
+    Bc = BHJM_magnet_cylinder(
+        field=field,
+        observers=obs,
+        dimension=dims,
+        polarization=pols,
+    )
+    Bcs = BHJM_cylinder_segment(
+        field=field,
+        observers=obs,
+        dimension=dims_cs,
+        polarization=pols,
+    )
+    np.testing.assert_allclose(Bc, Bcs)
diff --git a/tests/test_fields/test_CurrentLine.py b/tests/test_fields/test_CurrentLine.py
deleted file mode 100644
index 1ff7ddc8a..000000000
--- a/tests/test_fields/test_CurrentLine.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from magpylib._lib.fields.Current_Line_vector import Bfield_CurrentLineV
-from numpy import array
-import pytest
-import numpy as np
-
-# -------------------------------------------------------------------------------
-def test_Bfield_Zero_Length_segment():
-    # Check if Zero-length segments in vertices return valid 
-    errMsg = "Field sample outside of Line is unexpected"
-    mockResult = [0,0.72951356,0]
-
-    current = 5
-    pos = [0,0,0]
-
-    vertices = array([[-1,0,0],[1,0,5],[1,0,5]])
-    
-    results=Bfield_CurrentLineV(vertices,current,pos)
-    rounding = 4
-
-    for i in range(0,3):
-        assert round(mockResult[i],rounding)==round(results[i],rounding), errMsg
-    
-
-# -------------------------------------------------------------------------------
-def test_Bfield_CurrentLine_outside():
-    # Fundamental Positions in every 8 Octants
-    errMsg = "Field sample outside of Line is unexpected"
-    mockResults = [ [-15.426123, -42.10796, -12.922307],
-                    [67.176642, -3.154985, -10.209148],
-                    [-52.57675, 14.702422, 16.730058],
-                    [12.5054, 15.171589, -22.647928],
-                    [33.504425, -104.324783, 93.824852],
-                    [17.274412, 31.725278, -41.418518],
-                    [-22.39969, 56.344393, -3.576432],
-                    [-11.270571, -9.00747, 3.640508],]
-
-    testPosOut = array([[5.5,6,7],[6,7,-8],[7,-8,9],
-                        [-8,9,10],[7,-6,-5],[-8,7,-6],
-                        [-9,-8,7],[-10,-9,-8]])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-
-    current = -11111
-
-    vertices = array([ [-4,-4,-3],[3.5,-3.5,-2],[3,3,-1],
-                 [-2.5,2.5,0],[-2,-2,1],[1.5,-1.5,2],[1,1,3]])
-
-    results=[Bfield_CurrentLineV(vertices,current,pos) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_Bfield_onLine():
-    # Check if points that are on the line but 
-    # not on the segment still return valid results
-    # Expected for collinear points: [0,0,0]
-
-    vertices = np.array([[1,2,2],[1,2,30]])
-    current = 5
-    mockResults = np.zeros((2,3))
-
-    points = [vertices[0] + array([0,0,-3]), vertices[1] + array([0,0,3])] #on line
-    
-    results = array([Bfield_CurrentLineV(vertices,current,point) for point in points])
-    
-    assert np.all((results==mockResults).ravel())
\ No newline at end of file
diff --git a/tests/test_fields/test_CurrentLoop.py b/tests/test_fields/test_CurrentLoop.py
deleted file mode 100644
index 1985fc03f..000000000
--- a/tests/test_fields/test_CurrentLoop.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from magpylib._lib.fields.Current_CircularLoop import Bfield_CircularCurrentLoop
-from numpy import array, isnan
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_Bfield_singularity():
-    # Test the result for a field sample on the circular loop
-    # Expected: NaN
-    
-    # Definitions
-    current = 5
-    diameter = 5
-    calcPos = [0,2.5,0]
-
-    # Run
-    with pytest.warns(RuntimeWarning):
-        results = Bfield_CircularCurrentLoop(current,diameter,calcPos)
-        assert all(isnan(axis) for axis in results)
-
-# -------------------------------------------------------------------------------
-def test_CircularGetB_OuterLines():
-    errMsg = "Results from getB are unexpected"
-    mockResults = [ [-0.0, -51.469971, 30.236739],
-                    [0.0, 51.469971, 30.236739],
-                    [0.0, 63.447185, -2.422005],
-                    [0.0, -63.447185, -2.422005],
-                    [-0.0, -63.447185, -2.422005],
-                    [-0.0, 63.447185, -2.422005],
-                    [-0.0, -36.0076, 68.740859],
-                    [0.0, 36.0076, 68.740859],
-                    [0.0, 0.0, -133.812661], ] 
-
-    sideSurface = [0,3,0]
-    upperSurface = [0,1.5,1.5]
-    lowerSurface = [0,1.5,-1.5]
-    edgeBot = [0,2.5,1.5]
-    edgeTop = [0,2.5,-1.5]
-    edge = [0,3,1]
-    edge2 = [0,-3,1]
-    edge3 = [0,3,-1]
-    edge4 = [0,-3,-1]
-
-    testPosOut = array([  edgeTop, edgeBot, edge, edge2,
-                          edge3, edge4, lowerSurface,upperSurface,
-                        sideSurface])                  
-
-    current = 500
-    diameter = 5
-
-    results=[Bfield_CircularCurrentLoop(current,diameter,pos) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-    
-# -------------------------------------------------------------------------------
-def test_Bfield_outside():
-    # Fundamental Positions in every 8 Octants
-    errMsg = "Field sample outside of Box is unexpected"
-    mockResults = [ [-28.275526, -30.846029, -8.08718],
-                    [18.54694, 21.638096, -5.704383],
-                    [-12.588134, 14.386439, -3.348427],
-                    [8.919783, -10.034756, -2.091007],
-                    [29.112211, -24.953324, 9.416569],
-                    [-18.649955, 16.318711, 5.173529],
-                    [12.635187, 11.231277, 3.070725],
-                    [-8.943273, -8.048946, 1.934808],
-                    [0.0, 0.0, -69813.100267],]
-
-    testPosOut = array([ [5.5,6,7],[6,7,-8],[7,-8,9],
-                         [-8,9,10],[7,-6,-5],[-8,7,-6],
-                         [-9,-8,7],[-10,-9,-8],[0,0,0] ])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-    current = -111111
-    diameter = 2
-    results=[Bfield_CircularCurrentLoop(current,diameter,pos) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
diff --git a/tests/test_fields/test_MomentDipole.py b/tests/test_fields/test_MomentDipole.py
deleted file mode 100644
index 2b6db5ce4..000000000
--- a/tests/test_fields/test_MomentDipole.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from magpylib._lib.fields.Moment_Dipole import Bfield_Dipole
-from numpy import array, isnan
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_Bfield_singularity():
-    # Test the result for a field sample on the dipole itself
-    # Expected: NaN
-
-    # Definitions
-    mag=array([-1,2,-3])
-    calcPos = array([0,0,0])
-    
-    # Run
-    with pytest.warns(RuntimeWarning):
-        results = Bfield_Dipole(mag,calcPos)
-        assert all(isnan(axis) for axis in results)
-
-# -------------------------------------------------------------------------------
-def test_Bfield_outside():
-    # Fundamental Positions in every 8 Octants
-    errMsg = "Field sample outside of Box is unexpected"
-    mockResults = [ [-20.105974, -24.142649, -5.059827],
-                    [15.050978, 16.020022, -4.835365],
-                    [-10.051157, 11.206562, -3.526921],
-                    [6.41919, -7.423334, -0.818756],
-                    [19.933508, -17.961747, 9.301348],
-                    [-15.331571, 12.868214, 2.721131],
-                    [10.209707, 8.129991, 2.130329],
-                    [-6.319435, -6.356132, 1.677024],]
-
-    testPosOut = array([[5.5,6,7],[6,7,-8],[7,-8,9],
-                        [-8,9,10],[7,-6,-5],[-8,7,-6],
-                        [-9,-8,7],[-10,-9,-8]])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-
-    
-    mag=array([-11111,22222,-333333])
-
-    results=[Bfield_Dipole(mag,pos) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
diff --git a/tests/test_fields/test_PMBox.py b/tests/test_fields/test_PMBox.py
deleted file mode 100644
index 6b0ff5a68..000000000
--- a/tests/test_fields/test_PMBox.py
+++ /dev/null
@@ -1,161 +0,0 @@
-from magpylib._lib.fields.PM_Box import Bfield_Box
-from numpy import array, isnan
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_OLD():
-    errMsg = "Wrong field calculation for BfieldBox"
-    
-    mag=array([5,5,5])
-    dim=array([1,1,1])
-    rotatedPos = array([-19. ,   1.2,   8. ])
-    mockResults = array([ 1.40028858e-05, -4.89208175e-05, -7.01030695e-05])
-
-    result = Bfield_Box(mag,rotatedPos,dim)
-    rounding = 4
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_Edges():
-    from numpy import array,array_equal,append
-    from magpylib import source, Collection
-
-
-    mag=array([-111,222,-333])
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-    testPosEdge = []
-    corners = array([[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],[a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/2
-    
-    testPosEdge.extend(corners / array([2,1,1])) # testPosEdgesX = 
-    testPosEdge.extend(corners  / array([1,2,1])) # testPosEdgesY = 
-    testPosEdge.extend(corners / array([1,1,2])) # testPosEdgesZ =
-
-    with pytest.warns(RuntimeWarning):
-        results = [Bfield_Box(mag,pos,dim) for pos in testPosEdge]
-        assert all(all(isnan(val) for val in result) for result in results), "Results from getB is not NaN"
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_Faces():
-    from numpy import array,array_equal,append
-    from magpylib import source, Collection
-
-    mag=array([-111,222,-333])
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-    testPosFaces = []
-    corners = array([[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],[a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/2
-    testPosFaces.extend(corners / array([2,2,1]))  # testPosFaceX = 
-    testPosFaces.extend(corners / array([2,1,2])) # testPosFaceY = 
-    testPosFaces.extend(corners / array([1,2,2])) # testPosFaceZ = 
-
-    with pytest.warns(RuntimeWarning):
-        results = [Bfield_Box(mag,pos,dim) for pos in testPosFaces]
-        assert all(all(isnan(val) for val in result) for result in results), "Results from getB is not NaN"
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_OuterLines():
-    errMsg = "Unexpected Results for getB in Outer Lines"
-    from numpy import array,array_equal,append
-    from magpylib import source, Collection
-
-    mag=array([-111,222,-333])
-    mockResults = [array([ -7.66913751, -11.43130392,   3.90940536]), array([ 0.5814601 , -5.45527776, 10.643622  ]), 
-                  array([-19.62118983,   3.13850731,  -1.81978469]), array([12.53351242, -2.83751885,  4.91443196]), 
-                  array([ 0.5814601 , -5.45527776, 10.643622  ]), array([-19.62118983,   3.13850731,  -1.81978469]), 
-                  array([12.53351242, -2.83751885,  4.91443196]), array([ -7.66913751, -11.43130392,   3.90940536]), 
-                  array([ 2.40147269, -0.80424712,  5.73409625]), array([0.66977042, 1.19674994, 6.4908602 ]), 
-                  array([-1.60052144, 10.81979527, -0.6812673 ]), array([4.67176454, 8.81879821, 0.07549665]), 
-                  array([0.66977042, 1.19674994, 6.4908602 ]), array([-1.60052144, 10.81979527, -0.6812673 ]), 
-                  array([4.67176454, 8.81879821, 0.07549665]), array([ 2.40147269, -0.80424712,  5.73409625]), 
-                  array([-0.30055594, -3.65531213, -4.07927409]), array([ 2.06817563, -3.40748556, -3.12447919]), 
-                  array([-0.79620907,  0.60480782, -6.75413634]), array([ 2.56382876,  0.35698125, -5.79934144]), 
-                  array([ 2.06817563, -3.40748556, -3.12447919]), array([-0.79620907,  0.60480782, -6.75413634]), 
-                  array([ 2.56382876,  0.35698125, -5.79934144]), array([-0.30055594, -3.65531213, -4.07927409])]
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-    testPos = []
-    corners = array([[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],[a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/2
-    testPos.extend(corners * array([3,1,1]))  # testPosOuterX = 
-    testPos.extend(corners  * array([1,3,1])) # testPosOuterY = 
-    testPos.extend(corners  * array([1,1,3])) # testPosOuterZ = 
-
-
-    results = [Bfield_Box(mag,pos,dim) for pos in testPos]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_Corners():
-    from numpy import array,array_equal,append
-    from magpylib import source, Collection
-
-    mag=array([-111,222,-333])
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-
-    testPosCorners = array([[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],[a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/2
-
-    with pytest.warns(RuntimeWarning):
-        results = [Bfield_Box(mag,pos,dim) for pos in testPosCorners]
-        assert all(all(isnan(val) for val in result) for result in results), "Results from getB is not NaN"
-    
-# -------------------------------------------------------------------------------
-def test_BfieldBox_outside():
-    # Fundamental Positions in every 8 Octants, but inside
-    errMsg = "Field sample outside of Box is unexpected"
-    mockResults = [ [-487.520576, -575.369828, -104.423566],
-                    [364.861085, 382.575024, -106.896362],
-                    [-243.065706, 267.987035, -79.954987],
-                    [154.533798, -177.245393, -17.067326],
-                    [467.108616, -413.895715, 234.294815],
-                    [-364.043702, 300.956661, 72.402694],
-                    [242.976273, 191.057477, 54.841929],
-                    [-150.641259, -150.43341, 42.180744],]
-
-    testPosOut = array([[5.5,6,7],[6,7,-8],[7,-8,9],
-                        [-8,9,10],[7,-6,-5],[-8,7,-6],
-                        [-9,-8,7],[-10,-9,-8]])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-
-    mag=array([-11111,22222,-333333])
-    
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-
-    results=[Bfield_Box(mag,pos,dim) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_BfieldBox_inside():
-    # Fundamental Positions in every 8 Octants, but inside
-    errMsg = "Field sample inside of Box is unexpected"
-    mockResults = [ [-57.457487, 133.687466, -259.77011],
-                    [-56.028444, 147.488799, -250.092873],
-                    [-85.060153, 175.141795, -278.20544],
-                    [-28.425778, 161.340462, -268.528204],
-                    [-56.028444, 147.488799, -250.092873],
-                    [-85.060153, 175.141795, -278.20544],
-                    [-28.425778, 161.340462, -268.528204],
-                    [-57.457487, 133.687466, -259.77011],]
-
-    mag=array([-111,222,-333])
-
-    a,b,c = 2,3,4
-    dim=array([a,b,c])
-
-    testPosInside = array([[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],[a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/4
-
-    results=[Bfield_Box(mag,pos,dim) for pos in testPosInside]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-            
\ No newline at end of file
diff --git a/tests/test_fields/test_PMCylinder.py b/tests/test_fields/test_PMCylinder.py
deleted file mode 100644
index 2cbba8615..000000000
--- a/tests/test_fields/test_PMCylinder.py
+++ /dev/null
@@ -1,121 +0,0 @@
-from magpylib._lib.fields.PM_Cylinder import Bfield_Cylinder
-from numpy import array, isnan, pi
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_Bfield_singularity():
-    # Test the result for a field sample on the Cylinder
-    # Circular top face, Circular lower face, Side face
-    # Expected: [nan,nan,nan]
-    
-    # Definitions
-    iterDia = 50
-    mag = [1,2,3]
-    dim = [5,2]
-    sideSurface = [0,2.5,0]
-    upperSurface = [0,1.5,1]
-    lowerSurface = [0,1.5,-1]
-    edge = [0,2.5,1]
-    testPos = [sideSurface,upperSurface,lowerSurface,edge]
-
-    # Run
-    with pytest.warns(RuntimeWarning):
-        results = [Bfield_Cylinder(mag,pos,dim,iterDia) for pos in testPos]
-        assert all(all(isnan(axis) for axis in result) for result in results)
-
-# -------------------------------------------------------------------------------
-def test_Bfield_OuterPlanes():
-    # Field samples that are coplanar with samples that lead to singularity 
-    # These should be fine
-    errMsg = "Unexpected Results for Cylinder getB in Outer Lines"
-    mockResults = [ [253.334094, -4272.910608, 5590.91282],
-                    [253.334094, 5986.100534, 4906.984899],
-                    [253.334094, 5986.100534, 4906.984899],
-                    [253.334094, -4272.910608, 5590.91282],
-                    [1111.337474, 45438.762267, 3550.174662],
-                    [1111.337474, -40140.199172, 9255.362182],
-                    [283.635853, 1039.317535, 7080.751148],
-                    [344.814448, -9923.631738, 4923.14504],
-                    [344.814448, 11862.477551, 3470.750825],]
-
-    sideSurface = [0,3,0]
-    upperSurface = [0,1.5,1.5]
-    lowerSurface = [0,1.5,-1.5]
-    edge = [0,3,1]
-    edge2 = [0,-3,1]
-    edge3 = [0,3,-1]
-    edge4 = [0,-3,-1]
-    edgeBot = [0,2.5,1.5]
-    edgeTop = [0,2.5,-1.5]
-
-    testPosOut = array([ edge, edge2,edge3,
-                        edge4,lowerSurface,upperSurface,
-                        sideSurface,edgeBot,edgeTop])                              
-
-    mag=array([-11111,22222,-333333])
-
-    a,b = 2,3
-    dim=array([a,b])
-    iterDia = 50 #  Iterations calculating B-field from non-axial magnetization
-    rounding = 4
-    results = [Bfield_Cylinder(mag,pos,dim,iterDia) for pos in testPosOut]
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_Bfield_outside():
-    # Fundamental Positions in every 8 Octants
-    errMsg = "Field sample outside of Cylinder is unexpected"
-    mockResults = [ [-189.256324, -227.431954, -43.180409],
-                    [141.959926, 151.027113, -43.199818],
-                    [-94.766897, 105.650499, -32.010555],
-                    [60.407258, -69.865622, -7.040305],
-                    [184.386143, -166.307916, 90.330253],
-                    [-143.126907, 120.076143, 27.304572],
-                    [95.526535, 75.994148, 20.934673],
-                    [-59.166487, -59.557521, 16.283876],]
-
-    testPosOut = array([[5.5,6,7],[6,7,-8],[7,-8,9],
-                        [-8,9,10],[7,-6,-5],[-8,7,-6],
-                        [-9,-8,7],[-10,-9,-8]])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-
-    mag=array([-11111,22222,-333333])
-    
-    a,b = 2,3
-    dim=array([a,b])
-    iterDia = 50 #  Iterations calculating B-field from non-axial magnetization
-    results=[Bfield_Cylinder(mag,pos,dim,iterDia) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_Bfield_inside():
-    # Fundamental Positions in every 8 Octants, but inside
-    errMsg = "Field sample inside of Cylinder is unexpected"
-    mockResults = [ [-6488.54459, 12977.08918, -277349.820763],
-                    [-15392.748076, 350.405436, -269171.80507],
-                    [2099.31683, 199.530241, -268598.798536],
-                    [-15091.053488, 25985.080376, -270890.824672],
-                    [1797.602656, 26135.931534, -270317.818138],
-                    [2099.297244, 199.554278, -268598.798536],
-                    [-15091.033902, 25985.056339, -270890.824672],
-                    [1797.599514, 26135.911134, -270317.818138],
-                    [-15392.751218, 350.385036, -269171.80507],]
-
-    mag=array([-11111,22222,-333333])
-    a,b,c = 2,3,4
-    dim=a,b
-    testPosIn = array([[0,0,0],[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],
-                       [a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/(2*pi)
-    iterDia = 50 #  Iterations calculating B-field from non-axial magnetization
-    results=[Bfield_Cylinder(mag,pos,dim,iterDia) for pos in testPosIn]
-    rounding = 4
-
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
diff --git a/tests/test_fields/test_PMSphere.py b/tests/test_fields/test_PMSphere.py
deleted file mode 100644
index a237b9e66..000000000
--- a/tests/test_fields/test_PMSphere.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from magpylib._lib.fields.PM_Sphere import Bfield_Sphere
-from numpy import array, isnan, pi
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_Bfield_singularity():
-    # Test the result for a field sample on the Sphere
-    # 3 points in faces for each axis
-    # Expected: [nan,nan,nan]
-    
-    # Definitions
-    mag = [1,2,3]
-    diam = 5
-    r = diam/2
-    pos1 = [0,0,r]
-    pos2 = [0,r,0]
-    pos3 = [r,0,0]
-    testPos = [pos1,pos2,pos3]
-
-    # Run
-    with pytest.warns(RuntimeWarning):
-        results = [Bfield_Sphere(mag,pos,diam) for pos in testPos]
-        assert all(all(isnan(axis) for axis in result) for result in results)
-
-# -------------------------------------------------------------------------------
-def test_Bfield_outside():
-    # Fundamental Positions in every 8 Octants
-    errMsg = "Field sample outside of Box is unexpected"
-    mockResults = [ [-84.219706, -101.128492, -21.194554],
-                    [63.04539, 67.104513, -20.254327],
-                    [-42.102189, 46.941937, -14.773532],
-                    [26.888641, -31.094788, -3.429599],
-                    [83.497283, -75.23799, 38.961397],
-                    [-64.220733, 53.902248, 11.398246],
-                    [42.766322, 34.054828, 8.923501],
-                    [-26.470789, -26.624502, 7.024701],]
-
-    testPosOut = array([[5.5,6,7],[6,7,-8],[7,-8,9],
-                        [-8,9,10],[7,-6,-5],[-8,7,-6],
-                        [-9,-8,7],[-10,-9,-8]])                  
-    
-    #check field values to be within [1,100] adjust magnetization
-
-    mag=array([-11111,22222,-333333])
-    
-    a = 2
-    diam = a
-
-    results=[Bfield_Sphere(mag,pos,diam) for pos in testPosOut]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-
-# -------------------------------------------------------------------------------
-def test_Bfield_inside():
-    # Fundamental Positions in every 8 Octants, but inside
-    errMsg = "Field sample inside of Box is unexpected"
-    mockResults = [ [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],
-                    [-7407.333333, 14814.666667, -222222.0],]
-    mag=array([-11111,22222,-333333])
-    a,b,c = 2,3,4
-    diam=a
-    testPosIn = array([ [0,0,0],[a,b,c],[-a,b,c],[a,-b,c],[a,b,-c],
-                        [a,-b,-c],[-a,b,-c],[-a,-b,c],[-a,-b,-c]])/(2*pi)
-
-    results=[Bfield_Sphere(mag,pos,diam) for pos in testPosIn]
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-    
-
diff --git a/tests/test_getBH_dict.py b/tests/test_getBH_dict.py
new file mode 100644
index 000000000..a193b16f2
--- /dev/null
+++ b/tests/test_getBH_dict.py
@@ -0,0 +1,426 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput, MagpylibDeprecationWarning
+
+
+def test_getB_dict1():
+    """test field wrapper functions"""
+    pos_obs = (11, 2, 2)
+    mag = [111, 222, 333]
+    dim = [3, 3]
+
+    pm = magpy.magnet.Cylinder(polarization=mag, dimension=dim)
+    pm.move(np.linspace((0.5, 0, 0), (7.5, 0, 0), 15), start=-1)
+    pm.rotate_from_angax(np.linspace(0, 666, 25), "y", anchor=0)
+    pm.move([(0, x, 0) for x in np.linspace(0, 5, 5)], start=-1)
+    B2 = pm.getB(pos_obs)
+
+    pos = pm.position
+    rot = pm.orientation
+
+    dic = {"polarization": mag, "dimension": dim, "position": pos, "orientation": rot}
+    B1 = magpy.getB("Cylinder", pos_obs, **dic)
+
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getB_dict2():
+    """test field wrapper functions"""
+    pos_obs = (11, 2, 2)
+    mag = [111, 222, 333]
+    dim = [3, 3]
+    pos = [(1, 1, 1), (2, 2, 2), (3, 3, 3), (5, 5, 5)]
+
+    dic = {"polarization": mag, "dimension": dim, "position": pos}
+    B1 = magpy.getB("Cylinder", pos_obs, **dic)
+
+    pm = magpy.magnet.Cylinder(polarization=mag, dimension=dim, position=pos)
+    B2 = magpy.getB([pm], pos_obs)
+
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getH_dict1():
+    """test field wrapper functions"""
+    pos_obs = (11, 2, 2)
+    mag = [111, 222, 333]
+    dim = [3, 3]
+
+    dic = {"polarization": mag, "dimension": dim}
+    B1 = magpy.getH("Cylinder", pos_obs, **dic)
+
+    pm = magpy.magnet.Cylinder(polarization=mag, dimension=dim)
+    B2 = pm.getH(pos_obs)
+
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getB_dict3():
+    """test field wrapper functions"""
+    n = 25
+    pos_obs = np.array([1, 2, 2])
+    mag = [
+        [111, 222, 333],
+    ] * n
+    dim = [3, 3, 3]
+    pos = np.array([0, 0, 0])
+    rot = R.from_quat([(t, 0.2, 0.3, 0.4) for t in np.linspace(0, 0.1, n)])
+
+    dic = {"polarization": mag, "dimension": dim, "position": pos, "orientation": rot}
+    B1 = magpy.getB("Cuboid", pos_obs, **dic)
+
+    B2 = []
+    for i in range(n):
+        pm = magpy.magnet.Cuboid(
+            polarization=mag[i], dimension=dim, position=pos, orientation=rot[i]
+        )
+        B2 += [pm.getB(pos_obs)]
+    B2 = np.array(B2)
+    print(B1 - B2)
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getH_dict3():
+    """test field wrapper functions"""
+    pos_obs = (1, 2, 2)
+    mag = [[111, 222, 333], [22, 2, 2], [22, -33, -44]]
+    dim = 3
+
+    dic = {"polarization": mag, "diameter": dim}
+    B1 = magpy.getH("Sphere", pos_obs, **dic)
+
+    B2 = []
+    for i in range(3):
+        pm = magpy.magnet.Sphere(polarization=mag[i], diameter=dim)
+        B2 += [magpy.getH([pm], pos_obs)]
+    B2 = np.array(B2)
+
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getB_dict4():
+    """test field wrapper functions"""
+    n = 25
+    pos_obs = np.array([1, 2, 2])
+    mag = [
+        [111, 222, 333],
+    ] * n
+    dim = 3
+    pos = np.array([0, 0, 0])
+    rot = R.from_quat([(t, 0.2, 0.3, 0.4) for t in np.linspace(0, 0.1, n)])
+
+    dic = {"polarization": mag, "diameter": dim, "position": pos, "orientation": rot}
+    B1 = magpy.getB("Sphere", pos_obs, **dic)
+
+    B2 = []
+    for i in range(n):
+        pm = magpy.magnet.Sphere(
+            polarization=mag[i], diameter=dim, position=pos, orientation=rot[i]
+        )
+        B2 += [pm.getB(pos_obs)]
+    B2 = np.array(B2)
+    print(B1 - B2)
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+
+def test_getBH_dipole():
+    """test if Dipole implementation gives correct output"""
+    B = magpy.getB("Dipole", (1, 1, 1), moment=(1, 2, 3))
+    Btest = np.array([9.62250449e-08, 7.69800359e-08, 5.77350269e-08])
+    np.testing.assert_allclose(B, Btest)
+
+    H = magpy.getH("Dipole", (1, 1, 1), moment=(1, 2, 3))
+    Htest = np.array([0.07657346, 0.06125877, 0.04594407])
+    np.testing.assert_allclose(H, Htest, rtol=1e-05, atol=1e-08)
+
+
+def test_getBH_circle():
+    """test if Circle implementation gives correct output"""
+    B = magpy.getB("Circle", (0, 0, 0), current=1, diameter=2)
+    Btest = np.array([0, 0, 0.6283185307179586 * 1e-6])
+    np.testing.assert_allclose(B, Btest)
+
+    H = magpy.getH("Circle", (0, 0, 0), current=1, diameter=2)
+    Htest = np.array([0, 0, 0.6283185307179586 * 10 / 4 / np.pi])
+    np.testing.assert_allclose(H, Htest)
+
+    with pytest.warns(MagpylibDeprecationWarning):
+        B = magpy.getB("Loop", (0, 0, 0), current=1, diameter=2)
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_getBH_squeeze():
+    """test if squeeze works"""
+    B1 = magpy.getB("Circle", (0, 0, 0), current=1, diameter=2)
+    B2 = magpy.getB("Circle", [(0, 0, 0)], current=1, diameter=2)
+    B3 = magpy.getB("Circle", [(0, 0, 0)], current=1, diameter=2, squeeze=False)
+    B4 = magpy.getB("Circle", [(0, 0, 0)] * 2, current=1, diameter=2)
+
+    assert B1.ndim == 1
+    assert B2.ndim == 1
+    assert B3.ndim == 2
+    assert B4.ndim == 2
+
+
+def test_getBH_polyline():
+    """test getBHv with Polyline"""
+    H = magpy.getH(
+        "Polyline",
+        [(1, 1, 1), (1, 2, 3), (2, 2, 2)],
+        current=1,
+        segment_start=(0, 0, 0),
+        segment_end=[(0, 0, 0), (2, 2, 2), (2, 2, 2)],
+    )
+    x = (
+        np.array([[0, 0, 0], [0.02672612, -0.05345225, 0.02672612], [0, 0, 0]])
+        * 10
+        / 4
+        / np.pi
+    )
+    np.testing.assert_allclose(x, H, rtol=1e-05, atol=1e-08)
+
+
+def test_getBH_polyline2():
+    """test line with pos and rot arguments"""
+    x = 0.14142136 * 1e-6
+
+    # z-line on x=1
+    def getB_line(name):
+        return magpy.getB(
+            name,
+            (0, 0, 0),
+            current=1,
+            segment_start=(1, 0, -1),
+            segment_end=(1, 0, 1),
+        )
+
+    B1 = getB_line("Polyline")
+    expected = np.array([0, -x, 0])
+    np.testing.assert_allclose(B1, expected, rtol=1e-05, atol=1e-08)
+
+    with pytest.warns(MagpylibDeprecationWarning):
+        B1 = getB_line("Line")
+    np.testing.assert_allclose(B1, expected, rtol=1e-05, atol=1e-08)
+
+    # move z-line to x=-1
+    B2 = magpy.getB(
+        "Polyline",
+        (0, 0, 0),
+        position=(-2, 0, 0),
+        current=1,
+        segment_start=(1, 0, -1),
+        segment_end=(1, 0, 1),
+    )
+    np.testing.assert_allclose(B2, np.array([0, x, 0]), rtol=1e-05, atol=1e-08)
+
+    # rotate 1
+    rot = R.from_euler("z", 90, degrees=True)
+    B3 = magpy.getB(
+        "Polyline",
+        (0, 0, 0),
+        orientation=rot,
+        current=1,
+        segment_start=(1, 0, -1),
+        segment_end=(1, 0, 1),
+    )
+    np.testing.assert_allclose(B3, np.array([x, 0, 0]), rtol=1e-05, atol=1e-08)
+
+    # rotate 2
+    rot = R.from_euler("x", 90, degrees=True)
+    B4 = magpy.getB(
+        "Polyline",
+        (0, 0, 0),
+        orientation=rot,
+        current=1,
+        segment_start=(1, 0, -1),
+        segment_end=(1, 0, 1),
+    )
+    np.testing.assert_allclose(B4, np.array([0, 0, -x]), rtol=1e-05, atol=1e-08)
+
+    # rotate 3
+    rot = R.from_euler("y", 90, degrees=True)
+    B5 = magpy.getB(
+        "Polyline",
+        (0, 0, 0),
+        orientation=rot,
+        current=1,
+        segment_start=(1, 0, -1),
+        segment_end=(1, 0, 1),
+    )
+    np.testing.assert_allclose(B5, np.array([0, -x, 0]), rtol=1e-05, atol=1e-08)
+
+    # "scalar" vertices tiling
+    B = magpy.getB(
+        "Polyline",
+        observers=[(0, 0, 0)] * 5,
+        current=1,
+        vertices=np.linspace((0, 5, 5), (5, 5, 5), 6),
+    )
+    np.testing.assert_allclose(
+        B, np.array([[0.0, 0.0057735, -0.0057735]] * 5) * 1e-6, rtol=1e-6
+    )
+
+    # ragged sequence of vertices
+    observers = (1, 1, 1)
+    current = 1
+    vertices = [
+        [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)],
+        [(0, 0, 0), (3, 3, 3), (-3, 4, -5)],
+        [(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)],
+    ]
+    B1 = magpy.getB(
+        "Polyline",
+        observers=observers,
+        current=current,
+        vertices=vertices,
+    )
+    B2 = np.array(
+        [
+            magpy.getB(
+                "Polyline",
+                observers=observers,
+                current=current,
+                vertices=v,
+            )
+            for v in vertices
+        ]
+    )
+
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_getBH_Cylinder_FEM():
+    """test against FEM"""
+    ts = np.linspace(0, 2, 31)
+    obsp = [(t, t, t) for t in ts]
+
+    Bfem = np.array(
+        [
+            (-0.0300346254954609, -0.00567085248536589, -0.0980899423563197),
+            (-0.0283398999697276, -0.00136726650574628, -0.10058277210005),
+            (-0.0279636086648847, 0.00191033319772333, -0.102068667474779),
+            (-0.0287959403346942, 0.00385627171155148, -0.102086609934239),
+            (-0.0298064414078247, 0.00502298395545467, -0.101395051504575),
+            (-0.0309138327020785, 0.00585315159763698, -0.0994210978208941),
+            (-0.0304478836262897, 0.00637062970240076, -0.0956959733446996),
+            (-0.0294756102340511, 0.00796586777139283, -0.0909716586168481),
+            (-0.0257014555198541, 0.00901347002514088, -0.0839378050637996),
+            (-0.0203392379411272, 0.0113401710780434, -0.0758447872526493),
+            (-0.0141186721748514, 0.014275060463367, -0.0666447793887049),
+            (-0.00715638330645336, 0.0169990957749629, -0.0567988806666027),
+            (-0.000315107745706201, 0.0196025044167515, -0.0471345331233655),
+            (0.00570680487262037, 0.0216935664564627, -0.0379802748006986),
+            (0.0106937560983821, 0.0229598553802506, -0.029816827145783),
+            (0.0147153251512036, 0.0237740278061223, -0.0226247514391129),
+            (0.0173457909761498, 0.0240321714861875, -0.0167312828159773),
+            (0.0193755103218335, 0.023674091804632, -0.0119446813034152),
+            (0.0204291390948416, 0.0230735973599725, -0.00805340729977855),
+            (0.0207908036651642, 0.0221875600164857, -0.00496582571560478),
+            (0.020692112773328, 0.0211419193131436, -0.00269563642259977),
+            (0.0202607525969918, 0.0199897027578393, -0.000891130303443818),
+            (0.0195698099586468, 0.0187793271229261, 0.000332964123866357),
+            (0.0187342589014612, 0.0175395229794614, 0.00128198337775133),
+            (0.0178090320514157, 0.0163998590430951, 0.00196979345612218),
+            (0.0168069297247124, 0.0152418998801328, 0.00243910426847474),
+            (0.0158127817011691, 0.0141524929704775, 0.00274664013462767),
+            (0.0148149313600427, 0.013148844940711, 0.00293212192295656),
+            (0.013878964772737, 0.0121841676914905, 0.00302995618189322),
+            (0.0129803941608119, 0.0113011353152514, 0.00305232762136824),
+            (0.0121250819870128, 0.0104894041620816, 0.00303690098080925),
+        ]
+    )
+
+    # compare against FEM
+    B = magpy.getB(
+        "CylinderSegment",
+        obsp,
+        dimension=(1, 2, 1, 90, 360),
+        polarization=np.array((1, 2, 3)) * 1000 / np.sqrt(14),
+        position=(0, 0, 0.5),
+    )
+
+    err = np.linalg.norm(B - Bfem * 1000, axis=1) / np.linalg.norm(B, axis=1)
+    assert np.amax(err) < 0.01
+
+
+def test_getBH_solid_cylinder():
+    """compare multiple solid-cylinder solutions against each other"""
+    # combine multiple slices to one big Cylinder
+    B1 = magpy.getB(
+        "CylinderSegment",
+        (1, 2, 3),
+        dimension=[(0, 1, 2, 20, 120), (0, 1, 2, 120, 220), (0, 1, 2, 220, 380)],
+        polarization=(22, 33, 44),
+    )
+    B1 = np.sum(B1, axis=0)
+
+    # one big cylinder
+    B2 = magpy.getB(
+        "CylinderSegment",
+        (1, 2, 3),
+        dimension=(0, 1, 2, 0, 360),
+        polarization=(22, 33, 44),
+    )
+
+    # compute with solid cylinder code
+    B3 = magpy.getB(
+        "Cylinder",
+        (1, 2, 3),
+        dimension=(2, 2),
+        polarization=(22, 33, 44),
+    )
+
+    np.testing.assert_allclose(B1, B2)
+    np.testing.assert_allclose(B1, B3)
+
+
+def test_getB_dict_over_getB():
+    """test field wrapper functions"""
+    pos_obs = (11, 2, 2)
+    mag = [111, 222, 333]
+    dim = [3, 3]
+
+    pm = magpy.magnet.Cylinder(polarization=mag, dimension=dim)
+    pm.move(np.linspace((0.5, 0, 0), (7.5, 0, 0), 15))
+    pm.rotate_from_angax(np.linspace(0, 666, 25), "y", anchor=0)
+    pm.move([(0, x, 0) for x in np.linspace(0, 5, 5)])
+    B2 = pm.getB(pos_obs)
+
+    pos = pm.position
+    rot = pm.orientation
+
+    dic = {
+        "sources": "Cylinder",
+        "observers": pos_obs,
+        "polarization": mag,
+        "dimension": dim,
+        "position": pos,
+        "orientation": rot,
+    }
+    B1 = magpy.getB(**dic)
+
+    np.testing.assert_allclose(B1, B2, rtol=1e-12, atol=1e-12)
+
+    # test for kwargs if sources is not a string
+    dic["sources"] = pm
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.getB(**dic)
+
+
+def test_subclassing():
+    """Test side effects of suclasssing a source"""
+
+    # pylint: disable=unused-variable
+    class MyCuboid(magpy.magnet.Cuboid):
+        """Test subclass"""
+
+    MyCuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    B1 = magpy.getB("Cuboid", (0, 0, 0), polarization=(1, 1, 1), dimension=(1, 1, 1))
+    B2 = magpy.getB("MyCuboid", (0, 0, 0), polarization=(1, 1, 1), dimension=(1, 1, 1))
+
+    np.testing.assert_allclose(B1, B2)
diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py
new file mode 100644
index 000000000..6c54044e3
--- /dev/null
+++ b/tests/test_getBH_interfaces.py
@@ -0,0 +1,393 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibMissingInput
+
+# pylint: disable=unnecessary-lambda-assignment
+
+
+def test_getB_interfaces1():
+    """self-consistent test of different possibilities for computing the field"""
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+    poso = [[(-1, -1, -1)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+    B = magpy.getB(
+        "Cuboid",
+        (-1, -1, -1),
+        position=src.position,
+        polarization=(1, 2, 3),
+        dimension=(1, 2, 3),
+    )
+    B1 = np.tile(B, (2, 2, 1, 1))
+    B1 = np.swapaxes(B1, 0, 2)
+
+    B_test = magpy.getB(src, sens)
+    np.testing.assert_allclose(B1, B_test)
+
+    B_test = src.getB(poso)
+    np.testing.assert_allclose(B1, B_test)
+
+    B_test = src.getB(sens)
+    np.testing.assert_allclose(B1, B_test)
+
+    B_test = sens.getB(src)
+    np.testing.assert_allclose(B1, B_test)
+
+
+def test_getB_interfaces2():
+    """self-consistent test of different possibilities for computing the field"""
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+    poso = [[(-1, -1, -1)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+    B = magpy.getB(
+        "Cuboid",
+        (-1, -1, -1),
+        position=src.position,
+        polarization=(1, 2, 3),
+        dimension=(1, 2, 3),
+    )
+
+    B2 = np.tile(B, (2, 2, 2, 1, 1))
+    B2 = np.swapaxes(B2, 1, 3)
+
+    B_test = magpy.getB([src, src], sens)
+    np.testing.assert_allclose(B2, B_test)
+
+    B_test = sens.getB([src, src])
+    np.testing.assert_allclose(B2, B_test)
+
+
+def test_getB_interfaces3():
+    """self-consistent test of different possibilities for computing the field"""
+    src = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+    poso = [[(-1, -1, -1)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+    B = magpy.getB(
+        "Cuboid",
+        (-1, -1, -1),
+        position=src.position,
+        polarization=(1, 2, 3),
+        dimension=(1, 2, 3),
+    )
+
+    B3 = np.tile(B, (2, 2, 2, 1, 1))
+    B3 = np.swapaxes(B3, 0, 3)
+
+    B_test = magpy.getB(src, [sens, sens])
+    np.testing.assert_allclose(B3, B_test)
+
+    B_test = src.getB([poso, poso])
+    np.testing.assert_allclose(B3, B_test)
+
+    B_test = src.getB([sens, sens])
+    np.testing.assert_allclose(B3, B_test)
+
+
+def test_getH_interfaces1():
+    """self-consistent test of different possibilities for computing the field"""
+    mag = (22, -33, 44)
+    dim = (3, 2, 3)
+    src = magpy.magnet.Cuboid(polarization=mag, dimension=dim)
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+
+    poso = [[(-1, -2, -3)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+
+    H = magpy.getH(
+        "Cuboid",
+        (-1, -2, -3),
+        position=src.position,
+        polarization=mag,
+        dimension=dim,
+    )
+    H1 = np.tile(H, (2, 2, 1, 1))
+    H1 = np.swapaxes(H1, 0, 2)
+
+    H_test = magpy.getH(src, sens)
+    np.testing.assert_allclose(H1, H_test)
+
+    H_test = src.getH(poso)
+    np.testing.assert_allclose(H1, H_test)
+
+    H_test = src.getH(sens)
+    np.testing.assert_allclose(H1, H_test)
+
+    H_test = sens.getH(src)
+    np.testing.assert_allclose(H1, H_test)
+
+
+def test_getH_interfaces2():
+    """self-consistent test of different possibilities for computing the field"""
+    mag = (22, -33, 44)
+    dim = (3, 2, 3)
+    src = magpy.magnet.Cuboid(polarization=mag, dimension=dim)
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+
+    poso = [[(-1, -2, -3)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+
+    H = magpy.getH(
+        "Cuboid",
+        (-1, -2, -3),
+        position=src.position,
+        polarization=mag,
+        dimension=dim,
+    )
+
+    H2 = np.tile(H, (2, 2, 2, 1, 1))
+    H2 = np.swapaxes(H2, 1, 3)
+
+    H_test = magpy.getH([src, src], sens)
+    np.testing.assert_allclose(H2, H_test)
+
+    H_test = sens.getH([src, src])
+    np.testing.assert_allclose(H2, H_test)
+
+
+def test_getH_interfaces3():
+    """self-consistent test of different possibilities for computing the field"""
+    mag = (22, -33, 44)
+    dim = (3, 2, 3)
+    src = magpy.magnet.Cuboid(polarization=mag, dimension=dim)
+    src.move(np.linspace((0.1, 0.2, 0.3), (1, 2, 3), 10), start=-1)
+
+    poso = [[(-1, -2, -3)] * 2] * 2
+    sens = magpy.Sensor(pixel=poso)
+
+    H = magpy.getH(
+        "Cuboid",
+        (-1, -2, -3),
+        position=src.position,
+        polarization=mag,
+        dimension=dim,
+    )
+
+    H3 = np.tile(H, (2, 2, 2, 1, 1))
+    H3 = np.swapaxes(H3, 0, 3)
+
+    H_test = magpy.getH(src, [sens, sens])
+    np.testing.assert_allclose(H3, H_test)
+
+    H_test = src.getH([poso, poso])
+    np.testing.assert_allclose(H3, H_test)
+
+    H_test = src.getH([sens, sens])
+    np.testing.assert_allclose(H3, H_test)
+
+
+def test_dataframe_ouptut():
+    """test pandas dataframe output"""
+    max_path_len = 20
+    num_of_pix = 2
+
+    sources = [
+        magpy.magnet.Cuboid(polarization=(0, 0, 1000), dimension=(1, 1, 1)).move(
+            np.linspace((-4, 0, 0), (4, 0, 0), max_path_len), start=0
+        ),
+        magpy.magnet.Cylinder(
+            polarization=(0, 1000, 0), dimension=(1, 1), style_label="Cylinder1"
+        ).move(np.linspace((0, -4, 0), (0, 4, 0), max_path_len), start=0),
+    ]
+    pixel = np.linspace((0, 0, 0), (0, 3, 0), num_of_pix)
+    sens1 = magpy.Sensor(position=(0, 0, 1), pixel=pixel, style_label="sens1")
+    sens2 = sens1.copy(position=(0, 0, 3), style_label="sens2")
+    sens_col = magpy.Collection(sens1, sens2)
+
+    for field in "BH":
+        cols = [f"{field}{k}" for k in "xyz"]
+        df_field = getattr(magpy, f"get{field}")(
+            sources, sens_col, sumup=False, output="dataframe"
+        )
+        BH = getattr(magpy, f"get{field}")(
+            sources, sens_col, sumup=False, squeeze=False
+        )
+        for i in range(2):
+            np.testing.assert_array_equal(
+                BH[i].reshape(-1, 3),
+                df_field[df_field["source"] == df_field["source"].unique()[i]][cols],
+            )
+            np.testing.assert_array_equal(
+                BH[:, i].reshape(-1, 3),
+                df_field[df_field["path"] == df_field["path"].unique()[i]][cols],
+            )
+            np.testing.assert_array_equal(
+                BH[:, :, i].reshape(-1, 3),
+                df_field[df_field["sensor"] == df_field["sensor"].unique()[i]][cols],
+            )
+            np.testing.assert_array_equal(
+                BH[:, :, :, i].reshape(-1, 3),
+                df_field[df_field["pixel"] == df_field["pixel"].unique()[i]][cols],
+            )
+
+
+def test_dataframe_ouptut_sumup():
+    """test pandas dataframe output when sumup is True"""
+    sources = [
+        magpy.magnet.Cuboid(polarization=(0, 0, 1000), dimension=(1, 1, 1)),
+        magpy.magnet.Cylinder(polarization=(0, 1000, 0), dimension=(1, 1)),
+    ]
+    df_field = magpy.getB(sources, (0, 0, 0), sumup=True, output="dataframe")
+    np.testing.assert_allclose(
+        df_field[["Bx", "By", "Bz"]].values,
+        np.array([[-2.16489014e-14, 6.46446609e02, 6.66666667e02]]),
+    )
+
+
+def test_dataframe_ouptut_pixel_agg():
+    """test pandas dataframe output when sumup is True"""
+    src1 = magpy.magnet.Cuboid(polarization=(0, 0, 1000), dimension=(1, 1, 1))
+    sens1 = magpy.Sensor(position=(0, 0, 1), pixel=np.zeros((4, 5, 3)))
+    sens2 = sens1.copy(position=(0, 0, 2))
+    sens3 = sens1.copy(position=(0, 0, 3))
+
+    sources = (src1,)
+    sensors = sens1, sens2, sens3
+    df_field = magpy.getB(sources, sensors, pixel_agg="mean", output="dataframe")
+    np.testing.assert_allclose(
+        df_field[["Bx", "By", "Bz"]].values,
+        np.array(
+            [[0.0, 0.0, 134.78238624], [0.0, 0.0, 19.63857207], [0.0, 0.0, 5.87908614]]
+        ),
+    )
+
+
+def test_getBH_bad_output_type():
+    """test bad output in `getBH`"""
+    src = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    with pytest.raises(
+        ValueError,
+        match=r"The `output` argument must be one of ('ndarray', 'dataframe')*.",
+    ):
+        src.getB((0, 0, 0), output="bad_output_type")
+
+
+def test_sensor_handedness():
+    """test sensor handedness"""
+    k = 0.1
+    N = [5, 4, 3]
+
+    def ls(n):
+        return np.linspace(-k / 2, k / 2, n)
+
+    pixel = np.array([[x, y, z] for x in ls(N[0]) for y in ls(N[1]) for z in ls(N[2])])
+    pixel = pixel.reshape((*N, 3))
+    c = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, 1, 0)
+    )
+    sr = magpy.Sensor(
+        pixel=pixel,
+        position=(-1, 0, 0),
+        style_label="Sensor (right-handed)",
+        style_sizemode="absolute",
+    )
+    sl = sr.copy(
+        handedness="left",
+        style_label="Sensor (left-handed)",
+    )
+    sc = magpy.Collection(sr, sl)
+    sc.rotate_from_angax(np.linspace(0, 90, 6), "y", start=0)
+    # magpy.show(c, *sc)
+    B = c.getB(sc)
+
+    assert B.shape == (6, 2, 5, 4, 3, 3)
+    # second index is sensor index, ...,1:3 -> y&z from each sensor must be equal
+    np.testing.assert_allclose(B[:, 0, ..., 1:3], B[:, 1, ..., 1:3])
+
+    # second index is sensor index, ...,0 -> x from sl must opposite of x from sr
+    np.testing.assert_allclose(B[:, 0, ..., 0], -B[:, 1, ..., 0])
+
+
+@pytest.mark.parametrize(
+    ("module", "class_", "missing_arg"),
+    [
+        ("magnet", "Cuboid", "dimension"),
+        ("magnet", "Cylinder", "dimension"),
+        ("magnet", "CylinderSegment", "dimension"),
+        ("magnet", "Sphere", "diameter"),
+        ("magnet", "Tetrahedron", "vertices"),
+        ("magnet", "TriangularMesh", "vertices"),
+        ("current", "Circle", "diameter"),
+        ("current", "Polyline", "vertices"),
+        ("misc", "Triangle", "vertices"),
+    ],
+)
+def test_getB_on_missing_dimensions(module, class_, missing_arg):
+    """test_getB_on_missing_dimensions"""
+    with pytest.raises(
+        MagpylibMissingInput,
+        match=rf"Parameter `{missing_arg}` of .* must be set.",
+    ):
+        getattr(getattr(magpy, module), class_)().getB([0, 0, 0])
+
+
+@pytest.mark.parametrize(
+    ("module", "class_", "missing_arg", "kwargs"),
+    [
+        ("magnet", "Cuboid", "polarization", {"dimension": (1, 1, 1)}),
+        ("magnet", "Cylinder", "polarization", {"dimension": (1, 1)}),
+        (
+            "magnet",
+            "CylinderSegment",
+            "polarization",
+            {"dimension": (0, 1, 1, 45, 120)},
+        ),
+        ("magnet", "Sphere", "polarization", {"diameter": 1}),
+        (
+            "magnet",
+            "Tetrahedron",
+            "polarization",
+            {"vertices": [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]},
+        ),
+        (
+            "magnet",
+            "TriangularMesh",
+            "polarization",
+            {
+                "vertices": ((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)),
+                "faces": ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)),
+            },
+        ),
+        ("current", "Circle", "current", {"diameter": 1}),
+        ("current", "Polyline", "current", {"vertices": [[0, -1, 0], [0, 1, 0]]}),
+        (
+            "misc",
+            "Triangle",
+            "polarization",
+            {"vertices": [(0, 0, 0), (1, 0, 0), (0, 1, 0)]},
+        ),
+        ("misc", "Dipole", "moment", {}),
+    ],
+)
+def test_getB_on_missing_excitations(module, class_, missing_arg, kwargs):
+    """test_getB_on_missing_excitations"""
+    with pytest.raises(
+        MagpylibMissingInput,
+        match=rf"Parameter `{missing_arg}` of .* must be set.",
+    ):
+        getattr(getattr(magpy, module), class_)(**kwargs).getB([0, 0, 0])
+
+
+@pytest.mark.parametrize("field", ["H", "B", "M", "J"])
+def test_getHBMJ_self_consistency(field):
+    """test getHBMJ self consistency"""
+    sources = [
+        magpy.magnet.Cuboid(dimension=(1, 1, 1), polarization=(0, 0, 1)),
+        magpy.current.Circle(diameter=1, current=1),
+    ]
+    sens = magpy.Sensor(position=np.linspace((-1, 0, 0), (1, 0, 0), 10))
+    src = sources[0]
+
+    F1 = getattr(magpy, f"get{field}")(src, sens)
+    F2 = getattr(sens, f"get{field}")(src)
+    F3 = getattr(src, f"get{field}")(sens)
+    F4 = getattr(magpy.Collection(src, sens), f"get{field}")()
+
+    np.testing.assert_allclose(F1, F2)
+    np.testing.assert_allclose(F1, F3)
+    np.testing.assert_allclose(F1, F4)
diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py
new file mode 100644
index 000000000..1cac1edd7
--- /dev/null
+++ b/tests/test_getBH_level2.py
@@ -0,0 +1,610 @@
+from __future__ import annotations
+
+import unittest
+import warnings
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+
+def test_getB_level2_input_simple():
+    """test functionality of getB_level2 to combine various
+    inputs - simple position inputs
+    """
+    mag = (1, 2, 3)
+    dim_cuboid = (1, 2, 3)
+    dim_cyl = (1, 2)
+    pm1 = magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+    pm2 = magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+    pm3 = magpy.magnet.Cylinder(polarization=mag, dimension=dim_cyl)
+    pm4 = magpy.magnet.Cylinder(polarization=mag, dimension=dim_cyl)
+    col1 = magpy.Collection(pm1.copy())
+    col2 = magpy.Collection(pm1.copy(), pm2.copy())
+    col3 = magpy.Collection(pm1.copy(), pm2.copy(), pm3.copy())
+    col4 = magpy.Collection(pm1.copy(), pm2.copy(), pm3.copy(), pm4.copy())
+    pos_obs = (1, 2, 3)
+    sens1 = magpy.Sensor(position=pos_obs)
+    sens2 = magpy.Sensor(pixel=pos_obs)
+    sens3 = magpy.Sensor(position=(1, 2, 0), pixel=(0, 0, 3))
+
+    fb1 = magpy.getB(pm1, pos_obs)
+    fc1 = magpy.getB(pm3, pos_obs)
+    fb2 = np.array([fb1, fb1])
+    fc2 = np.array([fc1, fc1])
+
+    for poso, fb, fc in zip(
+        [pos_obs, sens1, sens2, sens3, [sens1, sens2]],
+        [fb1, fb1, fb1, fb1, fb2],
+        [fc1, fc1, fc1, fc1, fc2],
+        strict=False,
+    ):
+        src_obs_res = [
+            [pm1, poso, fb],
+            [pm3, poso, fc],
+            [[pm1, pm2], poso, [fb, fb]],
+            [[pm1, pm2, pm3], poso, [fb, fb, fc]],
+            [col1, poso, fb],
+            [col2, poso, 2 * fb],
+            [col3, poso, 2 * fb + fc],
+            [col4, poso, 2 * fb + 2 * fc],
+            [[pm1, col1], poso, [fb, fb]],
+            [[pm1, col1, col2, pm2, col4], poso, [fb, fb, 2 * fb, fb, 2 * fb + 2 * fc]],
+        ]
+
+        for sor in src_obs_res:
+            sources, observers, result = sor
+            result = np.array(result)
+
+            B = magpy.getB(sources, observers)
+            np.testing.assert_allclose(B, result)
+
+
+def test_getB_level2_input_shape22():
+    """test functionality of getB_level2 to combine various
+    inputs - position input with shape (2,2)
+    """
+    mag = (1, 2, 3)
+    dim_cuboid = (1, 2, 3)
+    dim_cyl = (1, 2)
+
+    def pm1():
+        return magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+
+    def pm2():
+        return magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+
+    def pm3():
+        return magpy.magnet.Cylinder(polarization=mag, dimension=dim_cyl)
+
+    def pm4():
+        return magpy.magnet.Cylinder(polarization=mag, dimension=dim_cyl)
+
+    col1 = magpy.Collection(pm1())
+    col2 = magpy.Collection(pm1(), pm2())
+    col3 = magpy.Collection(pm1(), pm2(), pm3())
+    col4 = magpy.Collection(pm1(), pm2(), pm3(), pm4())
+    pos_obs = [[(1, 2, 3), (1, 2, 3)], [(1, 2, 3), (1, 2, 3)]]
+    sens1 = magpy.Sensor(pixel=pos_obs)
+
+    fb22 = magpy.getB(pm1(), pos_obs)
+    fc22 = magpy.getB(pm3(), pos_obs)
+
+    for poso, fb_, fc_ in zip(
+        [pos_obs, sens1, [sens1, sens1, sens1]],
+        [fb22, fb22, [fb22, fb22, fb22]],
+        [fc22, fc22, [fc22, fc22, fc22]],
+        strict=False,
+    ):
+        fb = np.array(fb_)
+        fc = np.array(fc_)
+        src_obs_res = [
+            [pm1(), poso, fb],
+            [pm3(), poso, fc],
+            [[pm1(), pm2()], poso, [fb, fb]],
+            [[pm1(), pm2(), pm3()], poso, [fb, fb, fc]],
+            [col1, poso, fb],
+            [col2, poso, 2 * fb],
+            [col3, poso, 2 * fb + fc],
+            [col4, poso, 2 * fb + 2 * fc],
+            [[pm1(), col1], poso, [fb, fb]],
+            [
+                [pm1(), col1, col2, pm2(), col4],
+                poso,
+                [fb, fb, 2 * fb, fb, 2 * fb + 2 * fc],
+            ],
+        ]
+
+        for sor in src_obs_res:
+            sources, observers, result = sor
+            result = np.array(result)
+            B = magpy.getB(sources, observers)
+            np.testing.assert_allclose(B, result)
+
+
+def test_getB_level2_input_path():
+    """test functionality of getB_level2 to combine various
+    inputs - input objects with path
+    """
+    mag = (1, 2, 3)
+    dim_cuboid = (1, 2, 3)
+    pm1 = magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+    pm2 = magpy.magnet.Cuboid(polarization=mag, dimension=dim_cuboid)
+    sens1 = magpy.Sensor()
+    sens2 = magpy.Sensor(pixel=[(0, 0, 0), (0, 0, 1), (0, 0, 2)])
+
+    fb = pm1.getB([(x, 0, 0) for x in np.linspace(0, -1, 11)])
+
+    possis = np.linspace((0.1, 0, 0), (1, 0, 0), 10)
+    pm1.move(possis)
+    B = magpy.getB(pm1, (0, 0, 0))
+    result = fb
+    np.testing.assert_allclose(B, result)
+
+    B = magpy.getB(pm1, sens1)
+    result = fb
+    np.testing.assert_allclose(B, result)
+
+    B = magpy.getB([pm1, pm1], sens1)
+    result = np.array([fb, fb])
+    np.testing.assert_allclose(B, result)
+
+    fb = pm2.getB([[(x, 0, 0), (x, 0, 0)] for x in np.linspace(0, -1, 11)])
+    B = magpy.getB([pm1, pm1], [sens1, sens1])
+    result = np.array([fb, fb])
+    np.testing.assert_allclose(B, result)
+
+    fb = pm2.getB(
+        [[[(x, 0, 0), (x, 0, 1), (x, 0, 2)]] * 2 for x in np.linspace(0, -1, 11)]
+    )
+    B = magpy.getB([pm1, pm1], [sens2, sens2])
+    result = np.array([fb, fb])
+    np.testing.assert_allclose(B, result)
+
+
+def test_path_tile():
+    """Test if auto-tiled paths of objects will properly be reset
+    in getB_level2 before returning
+    """
+    pm1 = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    poz = np.linspace((10 / 33, 10 / 33, 10 / 33), (10, 10, 10), 33)
+    pm2.move(poz)
+
+    path1p = pm1.position
+    path1r = pm1.orientation
+
+    path2p = pm2.position
+    path2r = pm2.orientation
+
+    _ = magpy.getB([pm1, pm2], [0, 0, 0])
+
+    np.testing.assert_array_equal(
+        path1p,
+        pm1.position,
+        err_msg="FAILED: getB modified object path",
+    )
+    np.testing.assert_array_equal(
+        path1r.as_quat(),
+        pm1.orientation.as_quat(),
+        err_msg="FAILED: getB modified object path",
+    )
+    np.testing.assert_array_equal(
+        path2p,
+        pm2.position,
+        err_msg="FAILED: getB modified object path",
+    )
+    np.testing.assert_array_equal(
+        path2r.as_quat(),
+        pm2.orientation.as_quat(),
+        err_msg="FAILED: getB modified object path",
+    )
+
+
+def test_sensor_rotation1():
+    """Test simple sensor rotation using sin/cos"""
+    src = magpy.magnet.Cuboid(polarization=(1, 0, 0), dimension=(1, 1, 1))
+    sens = magpy.Sensor(position=(1, 0, 0))
+    sens.rotate_from_angax(np.linspace(0, 360, 56)[1:], "z", start=1, anchor=None)
+    B = src.getB(sens)
+
+    B0 = B[0, 0]
+    Brot = np.array(
+        [
+            (B0 * np.cos(phi), -B0 * np.sin(phi), 0)
+            for phi in np.linspace(0, 2 * np.pi, 56)
+        ]
+    )
+
+    np.testing.assert_allclose(B, Brot, rtol=1e-05, atol=1e-08)
+
+
+def test_sensor_rotation2():
+    """test sensor rotations with different combinations of inputs mag/col + sens/pos"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, 0, 2)
+    )
+    src2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, 0, 2)
+    )
+    col = magpy.Collection(src, src2)
+
+    poss = (0, 0, 0)
+    sens = magpy.Sensor(pixel=poss)
+    sens.rotate_from_angax([45, 90], "z")
+
+    sens2 = magpy.Sensor(pixel=poss)
+    sens2.rotate_from_angax(-45, "z")
+
+    x1 = np.array([-9.82, 0, 0]) * 1e-3
+    x2 = np.array([-6.94, 6.94, 0]) * 1e-3
+    x3 = np.array([0, 9.82, 0]) * 1e-3
+    x1b = np.array([-19.64, 0, 0]) * 1e-3
+    x2b = np.array([-13.89, 13.89, 0]) * 1e-3
+    x3b = np.array([0, 19.64, 0]) * 1e-3
+
+    B = magpy.getB(src, poss, squeeze=True)
+    Btest = x1
+    np.testing.assert_allclose(
+        B,
+        Btest,
+        rtol=1e-4,
+        atol=1e-5,
+        err_msg="FAIL: mag  +  pos",
+    )
+
+    B = magpy.getB([src], [sens], squeeze=True)
+    Btest = np.array([x1, x2, x3])
+    np.testing.assert_allclose(
+        B,
+        Btest,
+        rtol=1e-4,
+        atol=1e-5,
+        err_msg="FAIL: mag  +  sens_rot_path",
+    )
+
+    B = magpy.getB([src], [sens, poss], squeeze=True)
+    Btest = np.array([[x1, x1], [x2, x1], [x3, x1]])
+    np.testing.assert_allclose(
+        B,
+        Btest,
+        rtol=1e-4,
+        atol=1e-5,
+        err_msg="FAIL: mag  +  sens_rot_path, pos",
+    )
+
+    B = magpy.getB([src, col], [sens, poss], squeeze=True)
+    Btest = np.array(
+        [[[x1, x1], [x2, x1], [x3, x1]], [[x1b, x1b], [x2b, x1b], [x3b, x1b]]]
+    )
+    np.testing.assert_allclose(
+        B,
+        Btest,
+        rtol=1e-4,
+        atol=1e-5,
+        err_msg="FAIL: mag,col  +  sens_rot_path, pos",
+    )
+
+
+def test_sensor_rotation3():
+    """testing rotated static sensor path"""
+    # case static sensor rot
+    src = magpy.magnet.Cuboid(polarization=(1, 0, 0), dimension=(1, 1, 1))
+    sens = magpy.Sensor()
+    sens.rotate_from_angax(45, "z")
+    B0 = magpy.getB(src, sens)
+    B0t = np.tile(B0, (12, 1))
+
+    sens.move([(0, 0, 0)] * 12, start=-1)
+    Bpath = magpy.getB(src, sens)
+
+    np.testing.assert_allclose(B0t, Bpath)
+
+
+def test_object_tiling():
+    """test if object tiling works when input paths are of various lengths"""
+    # pylint: disable=no-member
+    src1 = magpy.current.Circle(current=1, diameter=1)
+    src1.rotate_from_angax(np.linspace(1, 31, 31), "x", anchor=(0, 1, 0), start=-1)
+
+    src2 = magpy.magnet.Cuboid(
+        polarization=(1, 1, 1), dimension=(1, 1, 1), position=(1, 1, 1)
+    )
+    src2.move([(1, 1, 1)] * 21, start=-1)
+
+    src3 = magpy.magnet.Cuboid(
+        polarization=(1, 1, 1), dimension=(1, 1, 1), position=(1, 1, 1)
+    )
+    src4 = magpy.magnet.Cuboid(
+        polarization=(1, 1, 1), dimension=(1, 1, 1), position=(1, 1, 1)
+    )
+
+    col = magpy.Collection(src3, src4)
+    src3.move([(1, 1, 1)] * 12, start=-1)
+    src4.move([(1, 1, 1)] * 31, start=-1)
+
+    possis = [[1, 2, 3]] * 5
+    sens = magpy.Sensor(pixel=possis)
+
+    assert src1.position.shape == (31, 3), "a1"
+    assert src2.position.shape == (21, 3), "a2"
+    assert src3.position.shape == (12, 3), "a3"
+    assert src4.position.shape == (31, 3), "a4"
+    assert sens.position.shape == (3,), "a5"
+
+    assert src1.orientation.as_quat().shape == (31, 4), "b1"
+    assert src2.orientation.as_quat().shape == (21, 4), "b2"
+    assert src3.orientation.as_quat().shape == (12, 4), "b3"
+    assert src4.orientation.as_quat().shape == (31, 4), "b4"
+    assert sens.orientation.as_quat().shape == (4,), "b5"
+
+    B = magpy.getB([src1, src2, col], [sens, possis])
+    assert B.shape == (3, 31, 2, 5, 3)
+
+    assert src1.position.shape == (31, 3), "c1"
+    assert src2.position.shape == (21, 3), "c2"
+    assert src3.position.shape == (12, 3), "c3"
+    assert src4.position.shape == (31, 3), "c4"
+    assert sens.position.shape == (3,), "c5"
+
+    assert src1.orientation.as_quat().shape == (31, 4), "d1"
+    assert src2.orientation.as_quat().shape == (21, 4), "d2"
+    assert src3.orientation.as_quat().shape == (12, 4), "d3"
+    assert src4.orientation.as_quat().shape == (31, 4), "d4"
+    assert sens.orientation.as_quat().shape == (4,), "d5"
+
+
+def test_superposition_vs_tiling():
+    """test superposition vs tiling, see issue #507"""
+
+    loop = magpy.current.Circle(current=10000, diameter=20, position=(1, 20, 10))
+    loop.rotate_from_angax([45, 90], "x")
+
+    sphere1 = magpy.magnet.Sphere(
+        polarization=(0, 0, 1), diameter=1, position=(20, 10, 1)
+    )
+    sphere1.rotate_from_angax([45, 90], "y")
+
+    sphere2 = magpy.magnet.Sphere(
+        polarization=(1, 0, 0), diameter=2, position=(10, 20, 1)
+    )
+    sphere2.rotate_from_angax([45, 90], "y")
+
+    loop_collection = magpy.Collection(loop, sphere1, sphere2)
+
+    observer_positions = [[0, 0, 0], [1, 1, 1]]
+
+    B1 = magpy.getB(loop, observer_positions)
+    B2 = magpy.getB(sphere1, observer_positions)
+    B3 = magpy.getB(sphere2, observer_positions)
+    superposed_B = B1 + B2 + B3
+
+    collection_B = magpy.getB(loop_collection, observer_positions)
+
+    np.testing.assert_allclose(superposed_B, collection_B)
+
+
+def test_squeeze_sumup():
+    """make sure that sumup does not lead to false output shape"""
+
+    s = magpy.Sensor(pixel=(1, 2, 3))
+    ss = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=1)
+
+    B1 = magpy.getB(ss, s, squeeze=False)
+    B2 = magpy.getB(ss, s, squeeze=False, sumup=True)
+
+    assert B1.shape == B2.shape
+
+
+def test_pixel_agg():
+    """test pixel aggregator"""
+    src1 = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1)).move(
+        [[1, 0, 0]]
+    )
+    sens1 = magpy.Sensor(
+        position=(0, 0, 1), pixel=np.zeros((4, 5, 3)), style_label="sens1 pixel(4,5)"
+    )
+    sens2 = sens1.copy(position=(0, 0, 2), style_label="sens2 pixel(4,5)")
+    sens3 = sens1.copy(position=(0, 0, 3), style_label="sens3 pixel(4,5)")
+    sens_col = magpy.Collection(sens1, sens2, sens3)
+
+    B1 = magpy.getB(src1, sens_col, squeeze=False, pixel_agg=None)
+    np.testing.assert_array_equal(B1.shape, (1, 2, 3, 4, 5, 3))
+
+    B2 = magpy.getB(src1, sens_col, squeeze=False, pixel_agg="mean")
+    np.testing.assert_array_equal(B2.shape, (1, 2, 3, 1, 3))
+
+    B3 = magpy.getB(src1, sens_col, squeeze=True, pixel_agg=None)
+    np.testing.assert_array_equal(B3.shape, (2, 3, 4, 5, 3))
+
+    B4 = magpy.getB(src1, sens_col, squeeze=True, pixel_agg="mean")
+    np.testing.assert_array_equal(B4.shape, (2, 3, 3))
+
+
+def test_pixel_agg_heterogeneous_pixel_shapes():
+    """test pixel aggregator with heterogeneous pixel shapes"""
+    src1 = magpy.magnet.Cuboid(polarization=(0, 0, 1), dimension=(1, 1, 1))
+    src2 = magpy.magnet.Sphere(polarization=(0, 0, 1), diameter=1, position=(2, 0, 0))
+    sens1 = magpy.Sensor(
+        position=(0, 0, 1), pixel=[0, 0, 0], style_label="sens1, pixel.shape = (3,)"
+    )
+    sens2 = sens1.copy(
+        position=(0, 0, 2), pixel=[1, 1, 1], style_label="sens2,  pixel.shape = (3,)"
+    )
+    sens3 = sens1.copy(
+        position=(0, 0, 3), pixel=[2, 2, 2], style_label="sens3,  pixel.shape = (3,)"
+    )
+    sens4 = sens1.copy(style_label="sens4,  pixel.shape = (3,)")
+    sens5 = sens2.copy(
+        pixel=np.zeros((4, 5, 3)) + 1, style_label="sens5,  pixel.shape = (3,)"
+    )
+    sens6 = sens3.copy(
+        pixel=np.zeros((4, 5, 1, 3)) + 2, style_label="sens6,  pixel.shape = (4,5,1,3)"
+    )
+    src_col = magpy.Collection(src1, src2)
+    sens_col1 = magpy.Collection(sens1, sens2, sens3)
+    sens_col2 = magpy.Collection(sens4, sens5, sens6)
+    sens_col1.rotate_from_angax([45], "z", anchor=(5, 0, 0))
+    sens_col2.rotate_from_angax([45], "z", anchor=(5, 0, 0))
+
+    # different pixel shapes without pixel_agg should raise an error
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.getB(src1, sens_col2, pixel_agg=None)
+
+    # bad pixel_agg numpy reference
+    with pytest.raises(AttributeError):
+        magpy.getB(src1, sens_col2, pixel_agg="bad_aggregator")
+
+    # good pixel_agg numpy reference, but non-reducing function
+    with pytest.raises(AttributeError):
+        magpy.getB(src1, sens_col2, pixel_agg="array")
+
+    B1 = magpy.getB(src1, sens_col1, squeeze=False, pixel_agg="max")
+    np.testing.assert_array_equal(B1.shape, (1, 2, 3, 1, 3))
+
+    B2 = magpy.getB(src1, sens_col2, squeeze=False, pixel_agg="max")
+    np.testing.assert_array_equal(B2.shape, (1, 2, 3, 1, 3))
+
+    B3 = magpy.getB(src1, sens_col1, squeeze=True)
+    np.testing.assert_array_equal(B3.shape, (2, 3, 3))
+
+    B4 = magpy.getB(src1, sens_col2, squeeze=True, pixel_agg="mean")
+    np.testing.assert_array_equal(B4.shape, (2, 3, 3))
+
+    # B3 and B4 should deliver the same results since pixel all have the same
+    # positions respectively for each sensor, so mean equals single value
+    np.testing.assert_allclose(B3, B4)
+
+    # Testing automatic vs manual aggregation (mean) with different pixel shapes
+    B_by_sens_agg_1 = magpy.getB(src_col, sens_col2, squeeze=False, pixel_agg="mean")
+    B_by_sens_agg_2 = []
+    for sens in sens_col2:
+        B = magpy.getB(src_col, sens, squeeze=False)
+        B = B.mean(axis=tuple(range(3 - B.ndim, -1)))
+        B = np.expand_dims(B, axis=-2)
+        B_by_sens_agg_2.append(B)
+    B_by_sens_agg_2 = np.concatenate(B_by_sens_agg_2, axis=2)
+
+    np.testing.assert_allclose(B_by_sens_agg_1, B_by_sens_agg_2)
+
+
+def test_pixel_agg3():
+    """test for various inputs"""
+    B1 = np.array([0.03122074, 0.03122074, 0.03122074])
+
+    e0 = np.array((1, 1, 1))
+    e1 = [(1, 1, 1)]
+    e2 = [(1, 1, 1)] * 2
+    e3 = [(1, 1, 1)] * 3
+
+    s0 = magpy.magnet.Cuboid(polarization=e0, dimension=e0)
+    c0 = magpy.Collection(s0)
+    s1 = magpy.magnet.Cuboid(polarization=e0, dimension=e0)
+    s2 = magpy.magnet.Cuboid(polarization=-e0, dimension=e0)
+    c1 = magpy.Collection(c0, s1, s2)
+
+    x0 = magpy.Sensor(pixel=e0)
+    x1 = magpy.Sensor(pixel=e1)
+    x2 = magpy.Sensor(pixel=e2)
+    x3 = magpy.Sensor(pixel=e3)
+
+    c2 = x0 + x1 + x2 + x3
+
+    for src, src_sh in zip(
+        [s0, c0, [s0, c0], c1, [s0, c0, c1, s1]], [1, 1, 2, 1, 4], strict=False
+    ):
+        for obs, obs_sh in zip(
+            [e0, e1, e2, e3, x0, x1, x2, x3, c2, [x0, x2, x3]],
+            [1] * 8 + [4, 3],
+            strict=False,
+        ):
+            for px_agg in ["mean", "average", "min"]:
+                np.testing.assert_allclose(
+                    magpy.getB(src, obs, pixel_agg=px_agg),
+                    np.squeeze(np.tile(B1, (src_sh, obs_sh, 1))),
+                    rtol=1e-5,
+                    atol=1e-8,
+                )
+
+    # same check with a path
+    s0.position = [(0, 0, 0)] * 5
+    for src, src_sh in zip(
+        [s0, c0, [s0, c0], c1, [s0, c0, c1, s1]], [1, 1, 2, 1, 4], strict=False
+    ):
+        for obs, obs_sh in zip(
+            [e0, e1, e2, e3, x0, x1, x2, x3, c2, [x0, x2, x3]],
+            [1] * 8 + [4, 3],
+            strict=False,
+        ):
+            for px_agg in ["mean", "average", "min"]:
+                np.testing.assert_allclose(
+                    magpy.getB(src, obs, pixel_agg=px_agg),
+                    np.squeeze(np.tile(B1, (src_sh, 5, obs_sh, 1))),
+                    rtol=1e-5,
+                    atol=1e-8,
+                )
+
+
+##############################################################
+def warnme1():
+    """test if in_out warning is thrown"""
+    sp = magpy.magnet.Sphere(
+        polarization=(1, 2, 3),
+        diameter=1,
+    )
+    sp.getB((1, 1, 1), in_out="inside")
+
+
+def warnme2():
+    """test if in_out warning is thrown"""
+    sp = magpy.magnet.Sphere(
+        polarization=(1, 2, 3),
+        diameter=1,
+    )
+    magpy.getH([sp, sp], (1, 1, 1), in_out="inside")
+
+
+class TestExceptions(unittest.TestCase):
+    """test class for exception testing"""
+
+    def test_warning(self):
+        """whatever"""
+        self.assertWarns(UserWarning, warnme1)
+        self.assertWarns(UserWarning, warnme2)
+
+
+##############################################################
+
+
+def do_not_warnme1():
+    """test if in_out warning is thrown"""
+    sp = magpy.magnet.Sphere(
+        polarization=(1, 2, 3),
+        diameter=1,
+    )
+    tetra = magpy.magnet.Tetrahedron(
+        polarization=(1, 2, 3),
+        vertices=[(1, 2, 3), (0, 0, 0), (1, 0, 0), (0, 1, 0)],
+    )
+    magpy.getH([sp, tetra], (1, 1, 1), in_out="inside")
+
+
+def do_not_warnme2():
+    """test if in_out warning is thrown"""
+    tetra = magpy.magnet.Tetrahedron(
+        polarization=(1, 2, 3),
+        vertices=[(1, 2, 3), (0, 0, 0), (1, 0, 0), (0, 1, 0)],
+    )
+    magpy.getH(tetra, (1, 1, 1), in_out="inside")
+
+
+def test_do_not_warn():
+    """do not warn"""
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        do_not_warnme1()
+        do_not_warnme2()
+        if len(w) > 0:
+            pytest.fail("WARNING SHOULD NOT HAVE BEEN RAISED")
diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py
new file mode 100644
index 000000000..81ec65253
--- /dev/null
+++ b/tests/test_input_checks.py
@@ -0,0 +1,1013 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+from magpylib._src.exceptions import (
+    MagpylibBadUserInput,
+    MagpylibDeprecationWarning,
+    MagpylibMissingInput,
+)
+from magpylib._src.fields.field_BH_dipole import BHJM_dipole
+
+# pylint: disable=unnecessary-lambda-assignment
+
+###########################################################
+###########################################################
+# OBJECT INPUTS
+
+
+@pytest.mark.parametrize(
+    "position",
+    [
+        (1, 2, 3),
+        (0, 0, 0),
+        ((1, 2, 3), (2, 3, 4)),
+        [(2, 3, 4)],
+        [2, 3, 4],
+        [[2, 3, 4], [3, 4, 5]],
+        [(2, 3, 4), (3, 4, 5)],
+        np.array((1, 2, 3)),
+        np.array(((1, 2, 3), (2, 3, 4))),
+    ],
+)
+def test_input_objects_position_good(position):
+    """good input: magpy.Sensor(position=position)"""
+
+    sens = magpy.Sensor(position=position)
+    np.testing.assert_allclose(sens.position, np.squeeze(np.array(position)))
+
+
+@pytest.mark.parametrize(
+    "position",
+    [
+        (1, 2),
+        (1, 2, 3, 4),
+        [(1, 2, 3, 4)] * 2,
+        (((1, 2, 3), (1, 2, 3)), ((1, 2, 3), (1, 2, 3))),
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        True,
+    ],
+)
+def test_input_objects_position_bad(position):
+    """bad input: magpy.Sensor(position=position)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.Sensor(position=position)
+
+
+@pytest.mark.parametrize(
+    "pixel",
+    [
+        (1, -2, 3),
+        (0, 0, 0),
+        ((1, 2, 3), (2, 3, 4)),
+        (((1, 2, 3), (2, -3, 4)), ((1, 2, 3), (2, 3, 4))),
+        [(2, 3, 4)],
+        [2, 3, 4],
+        [[-2, 3, 4], [3, 4, 5]],
+        [[[2, 3, 4], [3, 4, 5]]] * 4,
+        [(2, 3, 4), (3, 4, 5)],
+        np.array((1, 2, -3)),
+        np.array(((1, -2, 3), (2, 3, 4))),
+    ],
+)
+def test_input_objects_pixel_good(pixel):
+    """good input: magpy.Sensor(pixel=pixel)"""
+
+    sens = magpy.Sensor(pixel=pixel)
+    np.testing.assert_allclose(sens.pixel, pixel)
+
+
+@pytest.mark.parametrize(
+    "pixel",
+    [
+        (1, 2),
+        (1, 2, 3, 4),
+        [(1, 2, 3, 4)] * 2,
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        True,
+    ],
+)
+def test_input_objects_pixel_bad(pixel):
+    """bad input: magpy.Sensor(pixel=pixel)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.Sensor(position=(0, 0, 0), pixel=pixel)
+
+
+@pytest.mark.parametrize(
+    "orientation_rotvec",
+    [
+        None,
+        (0.1, 0.2, 0.3),
+        (0, 0, 0),
+        [(0.1, 0.2, 0.3)],
+        [(0.1, 0.2, 0.3)] * 5,
+    ],
+)
+def test_input_objects_orientation_good(orientation_rotvec):
+    """good input: magpy.Sensor(orientation=orientation_rotvec)"""
+
+    if orientation_rotvec is None:
+        sens = magpy.Sensor(orientation=None)
+        np.testing.assert_allclose(sens.orientation.as_rotvec(), (0, 0, 0))
+    else:
+        sens = magpy.Sensor(orientation=R.from_rotvec(orientation_rotvec))
+        np.testing.assert_allclose(
+            sens.orientation.as_rotvec(), np.squeeze(np.array(orientation_rotvec))
+        )
+
+
+@pytest.mark.parametrize(
+    "orientation_rotvec",
+    [
+        (1, 2),
+        (1, 2, 3, 4),
+        [(1, 2, 3, 4)] * 2,
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        True,
+    ],
+)
+def test_input_objects_orientation_bad(orientation_rotvec):
+    """bad input: magpy.Sensor(orientation=orientation_rotvec)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.Sensor(
+            position=(0, 0, 0), pixel=(0, 0, 0), orientation=orientation_rotvec
+        )
+
+
+@pytest.mark.parametrize(
+    "current",
+    [
+        None,
+        0,
+        1,
+        1.2,
+        np.array([1, 2, 3])[1],
+        -1,
+        -1.123,
+        True,
+    ],
+)
+def test_input_objects_current_good(current):
+    """good input: magpy.current.Circle(current)"""
+
+    src = magpy.current.Circle(current=current)
+    if current is None:
+        assert src.current is None
+    else:
+        np.testing.assert_allclose(src.current, current)
+
+
+@pytest.mark.parametrize(
+    "current",
+    [
+        (1, 2),
+        [(1, 2, 3, 4)] * 2,
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+    ],
+)
+def test_input_objects_current_bad(current):
+    """bad input: magpy.current.Circle(current)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.current.Circle(current)
+
+
+@pytest.mark.parametrize(
+    "diameter",
+    [
+        None,
+        0,
+        1,
+        1.2,
+        np.array([1, 2, 3])[1],
+        True,
+    ],
+)
+def test_input_objects_diameter_good(diameter):
+    """good input: magpy.current.Circle(diameter=inp)"""
+
+    src = magpy.current.Circle(diameter=diameter)
+    if diameter is None:
+        assert src.diameter is None
+    else:
+        np.testing.assert_allclose(src.diameter, diameter)
+
+
+@pytest.mark.parametrize(
+    "diameter",
+    [
+        (1, 2),
+        [(1, 2, 3, 4)] * 2,
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        -1,
+        -1.123,
+    ],
+)
+def test_input_objects_diameter_bad(diameter):
+    """bad input: magpy.current.Circle(diameter=diameter)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.current.Circle(diameter=diameter)
+
+
+@pytest.mark.parametrize(
+    "vertices",
+    [
+        None,
+        ((0, 0, 0), (0, 0, 0)),
+        ((1, 2, 3), (2, 3, 4)),
+        [(2, 3, 4), (-1, -2, -3)] * 2,
+        [[2, 3, 4], [3, 4, 5]],
+        np.array(((1, 2, 3), (2, 3, 4))),
+    ],
+)
+def test_input_objects_vertices_good(vertices):
+    """good input: magpy.current.Polyline(vertices=vertices)"""
+
+    src = magpy.current.Polyline(vertices=vertices)
+    if vertices is None:
+        assert src.vertices is None
+    else:
+        np.testing.assert_allclose(src.vertices, vertices)
+
+
+@pytest.mark.parametrize(
+    "vertices",
+    [
+        (1, 2),
+        [(1, 2, 3, 4)] * 2,
+        [(1, 2, 3)],
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        0,
+        -1.123,
+        True,
+    ],
+)
+def test_input_objects_vertices_bad(vertices):
+    """bad input: magpy.current.Polyline(vertices=vertices)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.current.Polyline(vertices=vertices)
+
+
+@pytest.mark.parametrize(
+    "pol_or_mom",
+    [
+        None,
+        (1, 2, 3),
+        (0, 0, 0),
+        [-1, -2, -3],
+        np.array((1, 2, 3)),
+    ],
+)
+def test_input_objects_magnetization_moment_good(pol_or_mom):
+    """
+    good input:
+        magpy.magnet.Cuboid(magnetization=moment),
+        magpy.misc.Dipole(moment=moment)
+    """
+
+    src = magpy.magnet.Cuboid(polarization=pol_or_mom)
+    src2 = magpy.misc.Dipole(moment=pol_or_mom)
+    if pol_or_mom is None:
+        assert src.polarization is None
+        assert src2.moment is None
+    else:
+        np.testing.assert_allclose(src.polarization, pol_or_mom)
+        np.testing.assert_allclose(src2.moment, pol_or_mom)
+
+
+@pytest.mark.parametrize(
+    "moment",
+    [
+        (1, 2),
+        [1, 2, 3, 4],
+        [(1, 2, 3)] * 2,
+        np.array([(1, 2, 3)] * 2),
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        0,
+        -1.123,
+        True,
+    ],
+)
+def test_input_objects_magnetization_moment_bad(moment):
+    """
+    bad input:
+        magpy.magnet.Cuboid(magnetization=moment),
+        magpy.misc.Dipole(moment=moment)
+    """
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.magnet.Cuboid(magnetization=moment)
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.misc.Dipole(moment=moment)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        None,
+        (1, 2, 3),
+        [11, 22, 33],
+        np.array((1, 2, 3)),
+    ],
+)
+def test_input_objects_dimension_cuboid_good(dimension):
+    """good input: magpy.magnet.Cuboid(dimension=dimension)"""
+
+    src = magpy.magnet.Cuboid(dimension=dimension)
+    if dimension is None:
+        assert src.dimension is None
+    else:
+        np.testing.assert_allclose(src.dimension, dimension)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        [-1, 2, 3],
+        (0, 1, 2),
+        (1, 2),
+        [1, 2, 3, 4],
+        [(1, 2, 3)] * 2,
+        np.array([(1, 2, 3)] * 2),
+        "x",
+        ["x", "y", "z"],
+        {"woot": 15},
+        0,
+        True,
+    ],
+)
+def test_input_objects_dimension_cuboid_bad(dimension):
+    """bad input: magpy.magnet.Cuboid(dimension=dimension)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.magnet.Cuboid(dimension=dimension)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        None,
+        (1, 2),
+        [11, 22],
+        np.array((1, 2)),
+    ],
+)
+def test_input_objects_dimension_cylinder_good(dimension):
+    """good input: magpy.magnet.Cylinder(dimension=dimension)"""
+
+    src = magpy.magnet.Cylinder(dimension=dimension)
+    if dimension is None:
+        assert src.dimension is None
+    else:
+        np.testing.assert_allclose(src.dimension, dimension)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        [-1, 2],
+        (0, 1),
+        (1,),
+        [1, 2, 3],
+        [(1, 2)] * 2,
+        np.array([(2, 3)] * 2),
+        "x",
+        ["x", "y"],
+        {"woot": 15},
+        0,
+        True,
+    ],
+)
+def test_input_objects_dimension_cylinder_bad(dimension):
+    """bad input: magpy.magnet.Cylinder(dimension=dimension)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.magnet.Cylinder(dimension=dimension)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        None,
+        (0, 2, 3, 0, 50),
+        (1, 2, 3, 40, 50),
+        [11, 22, 33, 44, 360],
+        [11, 22, 33, -44, 55],
+        np.array((1, 2, 3, 4, 5)),
+        [11, 22, 33, -44, -33],
+        (0, 2, 3, -10, 0),
+    ],
+)
+def test_input_objects_dimension_cylinderSegment_good(dimension):
+    """good input: magpy.magnet.CylinderSegment(dimension=dimension)"""
+
+    src = magpy.magnet.CylinderSegment(dimension=dimension)
+    if dimension is None:
+        assert src.dimension is None
+    else:
+        np.testing.assert_allclose(src.dimension, dimension)
+
+
+@pytest.mark.parametrize(
+    "dimension",
+    [
+        (1, 2, 3, 4),
+        (1, 2, 3, 4, 5, 6),
+        (0, 0, 3, 4, 5),
+        (2, 1, 3, 4, 5),
+        (-1, 2, 3, 4, 5),
+        (1, 2, 0, 4, 5),
+        (1, 2, -1, 4, 5),
+        (1, 2, 3, 5, 4),
+        [(1, 2, 3, 4, 5)] * 2,
+        np.array([(1, 2, 3, 4, 5)] * 2),
+        "x",
+        ["x", "y", "z", 1, 2],
+        {"woot": 15},
+        0,
+        True,
+    ],
+)
+def test_input_objects_dimension_cylinderSegment_bad(dimension):
+    """good input: magpy.magnet.CylinderSegment(dimension=dimension)"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.magnet.CylinderSegment(dimension=dimension)
+
+
+def test_input_objects_field_func_good():
+    """good input: magpy.misc.CustomSource(field_func=f)"""
+    # pylint: disable=unused-argument
+
+    # init empty = None
+    src = magpy.misc.CustomSource()
+    np.testing.assert_raises(MagpylibMissingInput, src.getB, (1, 2, 3))
+    np.testing.assert_raises(MagpylibMissingInput, src.getH, (1, 2, 3))
+
+    # None
+    src = magpy.misc.CustomSource(field_func=None)
+    np.testing.assert_raises(MagpylibMissingInput, src.getB, (1, 2, 3))
+    np.testing.assert_raises(MagpylibMissingInput, src.getH, (1, 2, 3))
+
+    # acceptable func with B and H return
+    def f(field, observers):  # noqa : ARG001
+        """3 in 3 out"""
+        return observers
+
+    src = magpy.misc.CustomSource(field_func=f)
+    np.testing.assert_allclose(src.getB((1, 2, 3)), (1, 2, 3))
+    np.testing.assert_allclose(src.getH((1, 2, 3)), (1, 2, 3))
+
+    # acceptable func with only B return
+    def ff(field, observers):
+        """3 in 3 out"""
+        if field == "B":
+            return observers
+        return None
+
+    src = magpy.misc.CustomSource(field_func=ff)
+    np.testing.assert_allclose(src.getB((1, 2, 3)), (1, 2, 3))
+    np.testing.assert_raises(MagpylibMissingInput, src.getH, (1, 2, 3))
+
+    # acceptable func with only B return
+    def fff(field, observers):
+        """3 in 3 out"""
+        if field == "H":
+            return observers
+        return None
+
+    src = magpy.misc.CustomSource(field_func=fff)
+    np.testing.assert_raises(MagpylibMissingInput, src.getB, (1, 2, 3))
+    np.testing.assert_allclose(src.getH((1, 2, 3)), (1, 2, 3))
+
+
+@pytest.mark.parametrize(
+    "func",
+    [
+        pytest.param(1, id="non-callable"),
+        pytest.param(lambda fieldd, observers, whatever: None, id="bad-arg-names"),  # noqa: ARG005
+        pytest.param(
+            lambda field, observers: 1 if field == "B" else None,  # noqa: ARG005
+            id="no-ndarray-return-on-B",
+        ),
+        pytest.param(
+            lambda field, observers: (1 if field == "H" else observers),
+            id="no-ndarray-return-on-H",
+        ),
+        pytest.param(
+            lambda field, observers: (  # noqa: ARG005
+                np.array([1, 2, 3]) if field == "B" else None
+            ),
+            id="bad-return-shape-on-B",
+        ),
+        pytest.param(
+            lambda field, observers: (
+                np.array([1, 2, 3]) if field == "H" else observers
+            ),
+            id="bad-return-shape-on-H",
+        ),
+    ],
+)
+def test_input_objects_field_func_bad(func):
+    """bad input: magpy.misc.CustomSource(field_func=f)"""
+    with pytest.raises(
+        MagpylibBadUserInput, match=r"Input parameter `field_func` must .*."
+    ):
+        magpy.misc.CustomSource(field_func=func)
+
+
+def test_missing_input_triangular_mesh():
+    """missing input checks for TriangularMesh"""
+
+    verts = np.array([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)])
+    tris = np.array([(0, 1, 2), (0, 1, 3), (1, 2, 3), (0, 2, 3)])
+
+    with pytest.raises(MagpylibMissingInput):
+        magpy.magnet.TriangularMesh(faces=tris)
+
+    with pytest.raises(MagpylibMissingInput):
+        magpy.magnet.TriangularMesh(vertices=verts)
+
+
+###########################################################
+###########################################################
+# DISPLAY
+
+
+@pytest.mark.parametrize(
+    "zoom",
+    [
+        (1, 2, 3),
+        -1,
+    ],
+)
+def test_input_show_zoom_bad(zoom):
+    """bad show zoom inputs"""
+    x = magpy.Sensor()
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.show(x, zoom=zoom, return_fig=True, backend="plotly")
+
+
+@pytest.mark.parametrize(
+    "animation",
+    [
+        (1, 2, 3),
+        -1,
+    ],
+)
+def test_input_show_animation_bad(animation):
+    """bad show animation inputs"""
+    x = magpy.Sensor()
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.show(x, animation=animation)
+
+
+@pytest.mark.parametrize(
+    "backend",
+    [
+        (1, 2, 3),
+        -1,
+        "x",
+        True,
+    ],
+)
+def test_input_show_backend_bad(backend):
+    """bad show backend inputs"""
+    x = magpy.Sensor()
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.show(x, backend=backend)
+
+
+###########################################################
+###########################################################
+# MOVE ROTATE
+
+
+@pytest.mark.parametrize(
+    "start_value",
+    [
+        "auto",
+        0,
+        1,
+        15,
+        -2,
+        -250,
+        np.array((1, 2, 3))[0],
+    ],
+)
+def test_input_move_start_good(start_value):
+    """good start inputs"""
+    x = magpy.Sensor(position=[(0, 0, i) for i in range(10)])
+    x.move((1, 0, 0), start=start_value)
+    assert isinstance(x.position, np.ndarray)
+
+
+@pytest.mark.parametrize(
+    "start_value",
+    [
+        1.1,
+        1.0,
+        "x",
+        None,
+        [11],
+        (1,),
+        np.array([(1, 2, 3, 4, 5)] * 2),
+        {"woot": 15},
+    ],
+)
+def test_input_move_start_bad(start_value):
+    """bad start inputs"""
+    x = magpy.Sensor(position=[(0, 0, i) for i in range(10)])
+    with pytest.raises(MagpylibBadUserInput):
+        x.move((1, 1, 1), start=start_value)
+
+
+@pytest.mark.parametrize("degrees", [True, False])
+def test_input_rotate_degrees_good(degrees):
+    """good degrees inputs"""
+    x = magpy.Sensor(position=(0, 0, 1))
+    x.rotate_from_angax(ang := 1.2345, "y", degrees=degrees, anchor=0)
+    if degrees:
+        ang = np.deg2rad(ang)
+    np.testing.assert_allclose(x.position, [np.sin(ang), 0, np.cos(ang)])
+
+
+@pytest.mark.parametrize(
+    "degrees",
+    [
+        1,
+        0,
+        1.1,
+        1.0,
+        "x",
+        None,
+        [True],
+        (1,),
+        np.array([(1, 2, 3, 4, 5)] * 2),
+        {"woot": 15},
+    ],
+)
+def test_input_rotate_degrees_bad(degrees):
+    """bad degrees inputs"""
+    x = magpy.Sensor()
+    with pytest.raises(MagpylibBadUserInput):
+        x.rotate_from_angax(10, "z", degrees=degrees)
+
+
+@pytest.mark.parametrize(
+    "axis",
+    [
+        (1, 2, 3),
+        (0, 0, 1),
+        [0, 0, 1],
+        np.array([0, 0, 1]),
+        "x",
+        "y",
+        "z",
+    ],
+)
+def test_input_rotate_axis_good(axis):
+    """good rotate axis inputs"""
+    x = magpy.Sensor()
+    x.rotate_from_angax(10, axis)
+    assert isinstance(x.position, np.ndarray)
+
+
+@pytest.mark.parametrize(
+    "axis",
+    [
+        (0, 0, 0),
+        (1, 2),
+        (1, 2, 3, 4),
+        1.1,
+        1,
+        "xx",
+        None,
+        True,
+        np.array([(1, 2, 3, 4, 5)] * 2),
+        {"woot": 15},
+    ],
+)
+def test_input_rotate_axis_bad(axis):
+    """bad rotate axis inputs"""
+    x = magpy.Sensor()
+    with pytest.raises(MagpylibBadUserInput):
+        x.rotate_from_angax(10, axis)
+
+
+@pytest.mark.parametrize(
+    "observers",
+    [
+        magpy.Sensor(),
+        magpy.Collection(magpy.Sensor()),
+        magpy.Collection(magpy.Sensor(), magpy.Sensor()),
+        (1, 2, 3),
+        [(1, 2, 3)] * 2,
+        [[(1, 2, 3)] * 2] * 3,
+        [magpy.Sensor(), magpy.Collection(magpy.Sensor())],
+        [magpy.Sensor(), magpy.Collection(magpy.Sensor(), magpy.Sensor())],
+        [magpy.Sensor(), (1, 2, 3)],
+        [magpy.Sensor(pixel=[[(1, 2, 3)] * 2] * 3), [[(1, 2, 3)] * 2] * 3],
+        [(1, 2, 3), magpy.Collection(magpy.Sensor())],
+        [(1, 2, 3), magpy.Collection(magpy.Sensor(), magpy.Sensor())],
+        [magpy.Sensor(), magpy.Collection(magpy.Sensor()), (1, 2, 3)],
+        [magpy.Sensor(), magpy.Collection(magpy.Sensor()), magpy.Sensor(), (1, 2, 3)],
+    ],
+)
+def test_input_observers_good(observers):
+    """good observers input"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+    B = src.getB(observers)
+    assert isinstance(B, np.ndarray)
+
+
+@pytest.mark.parametrize(
+    "observers",
+    [
+        "a",
+        None,
+        [],
+        ("a", "b", "c"),
+        [("a", "b", "c")],
+        magpy.misc.Dipole(moment=(1, 2, 3)),
+        [(1, 2, 3), [(1, 2, 3)] * 2],
+        [magpy.Sensor(), [(1, 2, 3)] * 2],
+        [[(1, 2, 3)] * 2, magpy.Collection(magpy.Sensor())],
+        [magpy.Sensor(pixel=(1, 2, 3)), ("a", "b", "c")],
+    ],
+)
+def test_input_observers_bad(observers):
+    """bad observers input"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+    with pytest.raises(MagpylibBadUserInput):
+        src.getB(observers)
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        [magpy.Sensor()],
+        [magpy.magnet.Cuboid()],
+        [magpy.Collection()],
+        [magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection()],
+        [
+            magpy.Sensor(),
+            magpy.Sensor(),
+            magpy.magnet.Cuboid(),
+            magpy.magnet.Cuboid(),
+            magpy.Collection(),
+            magpy.Collection(),
+        ],
+        [[magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection()]],
+        [(magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection())],
+    ],
+)
+def test_input_collection_good(children):
+    """good inputs: collection(inp)"""
+    col = magpy.Collection(*children)
+    assert isinstance(col, magpy.Collection)
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        "some_string",
+        None,
+        True,
+        1,
+        np.array((1, 2, 3)),
+        [magpy.Sensor(), [magpy.magnet.Cuboid(), magpy.Collection()]],
+    ],
+)
+def test_input_collection_bad(children):
+    """bad inputs: collection(inp)"""
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.Collection(children)
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        [magpy.Sensor()],
+        [magpy.magnet.Cuboid()],
+        [magpy.Collection()],
+        [magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection()],
+        [
+            magpy.Sensor(),
+            magpy.Sensor(),
+            magpy.magnet.Cuboid(),
+            magpy.magnet.Cuboid(),
+            magpy.Collection(),
+            magpy.Collection(),
+        ],
+        [[magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection()]],
+        [(magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection())],
+    ],
+)
+def test_input_collection_add_good(children):
+    """good inputs: collection.add(children)"""
+    col = magpy.Collection()
+    col.add(*children)
+    assert isinstance(col, magpy.Collection)
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        "some_string",
+        None,
+        True,
+        1,
+        np.array((1, 2, 3)),
+        ([magpy.Sensor(), [magpy.magnet.Cuboid(), magpy.Collection()]],),
+    ],
+)
+def test_input_collection_add_bad(children):
+    """bad inputs: collection.add(children)"""
+    col = magpy.Collection()
+    with pytest.raises(MagpylibBadUserInput):
+        col.add(children)
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        [magpy.Sensor()],
+        [magpy.magnet.Cuboid()],
+        [magpy.Collection()],
+        [magpy.Sensor(), magpy.magnet.Cuboid(), magpy.Collection()],
+        [[magpy.Sensor(), magpy.magnet.Cuboid()]],
+        [(magpy.Sensor(), magpy.magnet.Cuboid())],
+    ],
+)
+def test_input_collection_remove_good(children):
+    """good inputs: collection.remove(children)"""
+    col = magpy.Collection(*children)
+    assert col.children == (
+        list(children[0]) if isinstance(children[0], tuple | list) else children
+    )
+    col.remove(*children)
+    assert not col.children
+
+
+@pytest.mark.parametrize(
+    "children",
+    [
+        "some_string",
+        None,
+        True,
+        1,
+        np.array((1, 2, 3)),
+        [magpy.Sensor(), [magpy.Sensor()]],
+    ],
+)
+def test_input_collection_remove_bad(children):
+    """bad inputs: collection.remove(children)"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cuboid()
+    c1 = magpy.Collection()
+    col = magpy.Collection(x1, x2, s1, s2, c1)
+
+    with pytest.raises(MagpylibBadUserInput):
+        col.remove(children)
+
+
+def test_input_collection_bad_errors_arg():
+    """bad errors input"""
+    x1 = magpy.Sensor()
+    col = magpy.Collection()
+    with pytest.raises(MagpylibBadUserInput):
+        col.remove(x1, errors="w00t")
+
+
+@pytest.mark.parametrize("parent", [magpy.Collection(), None])
+def test_input_basegeo_parent_setter_good(parent):
+    """good inputs: obj.parent=parent"""
+    x = magpy.Sensor()
+    x.parent = parent
+    assert x.parent == parent
+
+
+@pytest.mark.parametrize(
+    "parent",
+    [
+        "some_string",
+        [],
+        True,
+        1,
+        np.array((1, 2, 3)),
+        [magpy.Collection()],
+        magpy.Sensor(),
+        magpy.magnet.Cuboid(),
+    ],
+)
+def test_input_basegeo_parent_setter_bad(parent):
+    """bad inputs: obj.parent=parent"""
+    x = magpy.Sensor()
+
+    with pytest.raises(MagpylibBadUserInput):
+        x.parent = parent
+
+    # when obj is good but has already a parent
+    x = magpy.Sensor()
+    magpy.Collection(x)
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.Collection(x)
+
+
+###########################################################
+###########################################################
+# GET BH
+
+
+@pytest.mark.parametrize("field", ["B", "H"])
+def test_input_getBH_field_good(field):
+    """good getBH field inputs"""
+    moms = np.array([[1, 2, 3]])
+    obs = np.array([[1, 2, 3]])
+    B = BHJM_dipole(field=field, observers=obs, moment=moms)
+    assert isinstance(B, np.ndarray)
+
+
+@pytest.mark.parametrize(
+    "field",
+    [
+        1,
+        0,
+        1.1,
+        1.0,
+        "x",
+        None,
+        [True],
+        (1,),
+        np.array([(1, 2, 3, 4, 5)] * 2),
+        {"woot": 15},
+    ],
+)
+def test_input_getBH_field_bad(field):
+    """bad getBH field inputs"""
+    moms = np.array([[1, 2, 3]])
+    obs = np.array([[1, 2, 3]])
+    with pytest.raises(MagpylibBadUserInput):
+        BHJM_dipole(field=field, observers=obs, moment=moms)
+
+
+def test_sensor_handedness():
+    """Test if handedness input"""
+    magpy.Sensor(handedness="right")
+    magpy.Sensor(handedness="left")
+    with pytest.raises(
+        MagpylibBadUserInput,
+        match=r"Sensor `handedness` must be either `'right'` or `'left'`",
+    ):
+        magpy.Sensor(handedness="not_right_or_left")
+
+
+def test_magnet_polarization_magnetization_input():
+    """test codependency and magnetization polarization inputs"""
+    # warning when magnetization is too low -> polarization confusion
+    mag = np.array([1, 2, 3]) * 1e6
+
+    with pytest.warns(
+        MagpylibDeprecationWarning,
+        match=r".* received a very low magnetization. .*",
+    ):
+        magpy.magnet.Cuboid(magnetization=[1, 2, 3])
+
+    # both polarization and magnetization at the same time
+    with pytest.raises(
+        ValueError,
+        match=r"The attributes magnetization and polarization are dependent. .*",
+    ):
+        magpy.magnet.Cuboid(polarization=[1, 2, 3], magnetization=mag)
+
+    # setting magnetization afterwards
+    c = magpy.magnet.Cuboid()
+    c.magnetization = mag
+    np.testing.assert_allclose(mag, c.magnetization)
+    np.testing.assert_allclose(mag * (4 * np.pi * 1e-7), c.polarization)
diff --git a/tests/test_magpyVector.py b/tests/test_magpyVector.py
deleted file mode 100644
index 36556be0b..000000000
--- a/tests/test_magpyVector.py
+++ /dev/null
@@ -1,192 +0,0 @@
-#%% MAIN
-
-import numpy as np
-from magpylib.source.magnet import Box, Cylinder, Sphere
-from magpylib.source.moment import Dipole
-from magpylib.source.current import Circular, Line
-from magpylib.vector import getBv_magnet, getBv_current, getBv_moment
-from magpylib.math import axisFromAngles
-from magpylib.math import angleAxisRotationV
-
-def test_vectorMagnet():
-
-    # calculate the B-field for the 3axis joystick system with
-    # vector and non-vecor code + compare
-
-    #base geometry
-    displM = 3
-    dCoT = 0
-    gap = 1
-    a,b,c = 4,4,4
-    Mx, My,Mz = 0,1000,0
-
-    mag = [Mx,My,Mz]
-    dim = [a,b,c]
-    posM = [displM,0,c/2+gap]
-    posS = [0,0,0]
-    anch = [0,0,gap+c+dCoT]
-
-    Nphi = 3
-    Npsi = 33
-    Nth = 11
-    NN = Nphi*Npsi*Nth
-    PHI = np.linspace(0,360,Nphi+1)[:-1]
-    PSI = np.linspace(0,360,Npsi)
-    TH = np.linspace(0,10,Nth)
-
-    MAG = np.array([mag]*NN)
-    POSo = np.array([posS]*NN)
-    POSm = np.array([posM]*NN)
-
-    ANG1 = np.array(list(PHI)*(Npsi*Nth))
-    AX1 = np.array([[0,0,1]]*NN)
-    ANCH1 = np.array([anch]*NN)
-
-    ANG2 = np.array([a for a in TH for _ in range(Nphi*Npsi)])
-    angles = np.array([a for a in PSI for _ in range(Nphi)]*Nth)
-    AX2 = angleAxisRotationV(np.array([[1,0,0]]*NN),angles,np.array([[0,0,1]]*NN),np.array([[0,0,0]]*NN))  
-    ANCH2 = np.array([anch]*NN)
-
-
-    # BOX ---------------------------------------------------------
-    # classic
-    def getB(phi,th,psi):
-        pm = Box(mag=mag,dim=dim,pos=posM)
-        axis = axisFromAngles([psi,90])    
-        pm.rotate(phi,[0,0,1],anchor=[0,0,0])    
-        pm.rotate(th,axis,anchor=anch)
-        return pm.getB(posS)
-    Bc = np.array([[[getB(phi,th,psi) for phi in PHI] for psi in PSI] for th in TH])
-
-    # vector
-    DIM = np.array([dim]*NN)
-    Bv = getBv_magnet('box',MAG,DIM,POSm,POSo,[ANG1,ANG2],[AX1,AX2],[ANCH1,ANCH2])
-    Bv = Bv.reshape([Nth,Npsi,Nphi,3])
-
-    # assert
-    assert np.amax(Bv-Bc) < 1e-10, "bad magpylib vector Box"
-
-    # SPHERE ---------------------------------------------------------
-    # classic
-    dim2 = 4
-    def getB2(phi,th,psi):
-        pm = Sphere(mag=mag,dim=dim2,pos=posM)
-        axis = axisFromAngles([psi,90])    
-        pm.rotate(phi,[0,0,1],anchor=[0,0,0])    
-        pm.rotate(th,axis,anchor=anch)
-        return pm.getB(posS)
-    Bc = np.array([[[getB2(phi,th,psi) for phi in PHI] for psi in PSI] for th in TH])
-
-    # vector
-    DIM2 = np.array([dim2]*NN)
-    Bv = getBv_magnet('sphere',MAG,DIM2,POSm,POSo,[ANG1,ANG2],[AX1,AX2],[ANCH1,ANCH2])
-    Bv = Bv.reshape([Nth,Npsi,Nphi,3])
-
-    #assert
-    assert np.amax(Bv-Bc) < 1e-10, "bad magpylib vector Sphere"
-
-
-def test_vectorMagnetCylinder():
-
-    MAG = np.array([[0,0,-44],[0,0,55],[11,22,33],[-14,25,36],[17,-28,39],[-10,-21,32],[0,12,23],[0,-14,25],[16,0,27],[-18,0,29]])
-    POSM = np.ones([10,3])
-    POSO = MAG*0.1*np.array([.8,-1,-1.3])+POSM
-    DIM = np.ones([10,2])
-
-    Bv = getBv_magnet('cylinder',MAG,DIM,POSM,POSO)
-
-    Bc = []
-    for mag,posM,posO,dim in zip(MAG,POSM,POSO,DIM):
-        pm = Cylinder(mag,dim,posM)
-        Bc += [pm.getB(posO)]
-    Bc = np.array(Bc)
-    
-    assert np.amax(abs(Bv-Bc)) < 1e-15
-
-    # inside cylinder testing and iterDia
-
-    MAG = np.array([[0,0,1],[0,1,0],[1,0,0],[0,1,1],[1,0,1],[1,1,0],[1,1,1]])
-    POSO = np.zeros([7,3])-.1
-    DIM = np.ones([7,2])
-    POSM = np.zeros([7,3])
-
-    Bv = getBv_magnet('cylinder',MAG,DIM,POSM,POSO,Nphi0=11)
-
-    Bc = []
-    for mag,posM,posO,dim in zip(MAG,POSM,POSO,DIM):
-        pm = Cylinder(mag,dim,posM,iterDia=11)
-        Bc += [pm.getB(posO)]
-    Bc = np.array(Bc)
-    
-    assert np.amax(abs(Bv-Bc)) < 1e-15
-
-
-
-def test_vectorMomentDipole():
-
-    MOM = np.array([[0,0,2],[0,0,55],[11,22,33],[-14,25,36],[17,-28,39],[-10,-21,32],[0,12,23],[0,-14,25],[16,0,27],[-18,0,29]])
-    POSM = np.ones([10,3])
-    POSO = MOM*0.1*np.array([.8,-1,-1.3])+POSM
-    
-    Bv = getBv_moment('dipole',MOM,POSM,POSO)
-
-    Bc = []
-    for mom,posM,posO in zip(MOM,POSM,POSO):
-        pm = Dipole(mom,posM)
-        Bc += [pm.getB(posO)]
-    Bc = np.array(Bc)
-    
-    assert np.amax(abs(Bv-Bc)) < 1e-15
-
-
-
-def test_vectorCurrentCircular():
-    
-    I = np.ones([10])
-    D = np.ones([10])*4
-    Pm = np.zeros([10,3])
-    Po = np.array([[0,0,1],[0,0,-1],[1,1,0],[1,-1,0],[-1,-1,0],[-1,1,0],[5,5,0],[5,-5,0],[-5,-5,0],[-5,5,0]])
-
-    Bc = []
-    for i,d,pm,po in zip(I,D,Pm,Po):
-        s = Circular(curr=i,dim=d,pos=pm)
-        Bc += [s.getB(po)]
-    Bc = np.array(Bc)
-
-    Bv = getBv_current('circular',I,D,Pm,Po)
-
-    assert np.amax(abs(Bc-Bv))<1e-10
-
-
-def test_vectorLine():
-
-    #general cases
-    NN=100
-    V = np.random.rand(NN,3)-.5
-    V1 = V[:-1]
-    V2 = V[1:]
-    DIM = np.array([[v1,v2] for v1,v2 in zip(V1,V2)])
-    I0 = np.ones([NN-1])
-    Po = np.ones((NN-1,3))
-    Pm = np.zeros((NN-1,3))
-    
-    Bc = []
-    for dim,i0,po in zip(DIM,I0,Po):
-        s = Line(curr=i0,vertices=dim)
-        Bc += [s.getB(po)]
-    Bc = np.array(Bc)
-
-    Bv = getBv_current('line',I0,DIM,Pm,Po)
-    assert np.amax(abs(Bc-Bv))<1e-12
-
-    #special cases
-    V = np.array([[0,0,0],[1,2,3],[1,2,3],[3,3,3],[1,1,1],[1,1,2],[1,1,0]])
-    V1 = V[:-1]
-    V2 = V[1:]
-    DIM = np.array([[v1,v2] for v1,v2 in zip(V1,V2)])
-    I0 = np.ones([6])
-    Po = np.ones((6,3))
-    Pm = np.zeros((6,3))
-
-    Bv = getBv_current('line',I0,DIM,Pm,Po)
-    assert [all(b == 0) for b in Bv] == [False, True, False, True, True, True]
\ No newline at end of file
diff --git a/tests/test_misc.py b/tests/test_misc.py
new file mode 100644
index 000000000..795daa568
--- /dev/null
+++ b/tests/test_misc.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+import magpylib as magpy
+
+
+def test_bare_init():
+    """test if magpylib object can be initialized without attributes"""
+    magpy.current.Circle()
+    magpy.current.Polyline()
+    magpy.magnet.Cuboid()
+    magpy.magnet.Cylinder()
+    magpy.magnet.CylinderSegment()
+    magpy.magnet.Sphere()
+    magpy.misc.Dipole()
+    magpy.misc.CustomSource()
diff --git a/tests/test_numerical_stability.py b/tests/test_numerical_stability.py
new file mode 100644
index 000000000..1af576208
--- /dev/null
+++ b/tests/test_numerical_stability.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_loop_field():
+    """
+    test numerical instability of current loop field at r=0
+
+    Many users have seen that by continued rotation about an anchor
+    the field become instable. This is a result of small displacements from the axis
+    where the field is evaluated due to floating-point errors. see paper Leitner2021.
+    """
+    lop = magpy.current.Circle(current=1000, diameter=1)
+
+    anch = (0, 0, 1)
+    B = []
+    for _ in range(1000):
+        lop.rotate_from_angax(100, "x", anchor=anch, start=-1)
+        B += [lop.getB(anch)]
+
+    B = np.array(B)
+    normB = np.linalg.norm(B, axis=1)
+    norms = normB / normB[0]
+
+    np.testing.assert_allclose(norms, np.ones(1000))
diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py
new file mode 100644
index 000000000..52998c77a
--- /dev/null
+++ b/tests/test_obj_BaseGeo.py
@@ -0,0 +1,636 @@
+from __future__ import annotations
+
+import re
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+from magpylib._src.obj_classes.class_BaseGeo import BaseGeo
+
+# pylint: disable=no-member
+
+
+def test_BaseGeo_basics():
+    """fundamental usage test"""
+
+    ptest = np.array(
+        [
+            [0, 0, 0],
+            [1, 2, 3],
+            [0, 0, 0],
+            [0, 0, 0],
+            [0, 0, 0],
+            [0, 0, 0],
+            [0.67545246, -0.6675014, -0.21692852],
+        ]
+    )
+
+    otest = np.array(
+        [
+            [0, 0, 0],
+            [0.1, 0.2, 0.3],
+            [0.1, 0.2, 0.3],
+            [0, 0, 0],
+            [0.20990649, 0.41981298, 0.62971947],
+            [0, 0, 0],
+            [0.59199676, 0.44281248, 0.48074693],
+        ]
+    )
+
+    poss, rots = [], []
+
+    bgeo = BaseGeo((0, 0, 0), None)
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    bgeo.position = (1, 2, 3)
+    bgeo.orientation = R.from_rotvec((0.1, 0.2, 0.3))
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    bgeo.move((-1, -2, -3))
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    rot = R.from_rotvec((-0.1, -0.2, -0.3))
+    bgeo.rotate(rotation=rot, start=-1)
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    bgeo.rotate_from_angax(angle=45, axis=(1, 2, 3))
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    bgeo.rotate_from_angax(-np.pi / 4, (1, 2, 3), degrees=False)
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    rot = R.from_rotvec((0.1, 0.2, 0.3))
+    bgeo.rotate(rot, anchor=(3, 2, 1), start=-1)
+    bgeo.rotate_from_angax(33, (3, 2, 1), anchor=0, start=-1)
+    poss += [bgeo.position.copy()]
+    rots += [bgeo.orientation.as_rotvec()]
+
+    poss = np.array(poss)
+    rots = np.array(rots)
+
+    # avoid generating different zeros in macos CI tests (atol=1e-6)
+    np.testing.assert_allclose(
+        poss,
+        ptest,
+        atol=1e-6,
+        err_msg="test_BaseGeo bad position",
+    )
+    np.testing.assert_allclose(
+        rots,
+        otest,
+        atol=1e-6,
+        err_msg="test_BaseGeo bad orientation",
+    )
+
+
+def test_rotate_vs_rotate_from():
+    """testing rotate vs rotate_from_angax"""
+    roz = [
+        (0.1, 0.2, 0.3),
+        (-0.1, -0.1, -0.1),
+        (0.2, 0, 0),
+        (0.3, 0, 0),
+        (0, 0, 0.4),
+        (0, -0.2, 0),
+    ]
+
+    bg1 = BaseGeo(position=(3, 4, 5), orientation=R.from_quat((0, 0, 0, 1)))
+    for ro in roz:
+        rroz = R.from_rotvec((ro,))
+        bg1.rotate(rotation=rroz, anchor=(-3, -2, 1))
+    pos1 = bg1.position
+    ori1 = bg1.orientation.as_quat()
+
+    bg2 = BaseGeo(position=(3, 4, 5), orientation=R.from_quat((0, 0, 0, 1)))
+    angs = np.linalg.norm(roz, axis=1)
+    for ang, ax in zip(angs, roz, strict=False):
+        bg2.rotate_from_angax(angle=[ang], degrees=False, axis=ax, anchor=(-3, -2, 1))
+    pos2 = bg2.position
+    ori2 = bg2.orientation.as_quat()
+
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+
+def test_BaseGeo_reset_path():
+    """testing reset path"""
+    # pylint: disable=protected-access
+    bg = BaseGeo((0, 0, 0), R.from_quat((0, 0, 0, 1)))
+    bg.move([(1, 1, 1)] * 11)
+
+    assert len(bg._position) == 12, "bad path generation"
+
+    bg.reset_path()
+    assert len(bg._position) == 1, "bad path reset"
+
+
+def test_BaseGeo_anchor_None():
+    """testing rotation with None anchor"""
+    pos = np.array([1, 2, 3])
+    bg = BaseGeo(pos, R.from_quat((0, 0, 0, 1)))
+    bg.rotate(R.from_rotvec([(0.1, 0.2, 0.3), (0.2, 0.4, 0.6)]))
+
+    pos3 = np.array([pos] * 3)
+    rot3 = np.array([(0, 0, 0), (0.1, 0.2, 0.3), (0.2, 0.4, 0.6)])
+    np.testing.assert_allclose(
+        bg.position,
+        pos3,
+        err_msg="None rotation changed position",
+    )
+    np.testing.assert_allclose(
+        bg.orientation.as_rotvec(),
+        rot3,
+        err_msg="None rotation did not adjust rot",
+    )
+
+
+def evall(obj):
+    """return pos and orient of object"""
+    # pylint: disable=protected-access
+    pp = obj._position
+    rr = obj._orientation.as_quat()
+    rr = np.array([r / max(r) for r in rr])
+    return (pp, rr)
+
+
+def test_attach():
+    """test attach functionality"""
+    bg = BaseGeo([0, 0, 0], R.from_rotvec((0, 0, 0)))
+    rot_obj = R.from_rotvec([(x, 0, 0) for x in np.linspace(0, 10, 11)])
+    bg.rotate(rot_obj, start=-1)
+
+    bg2 = BaseGeo([0, 0, 0], R.from_rotvec((0, 0, 0)))
+    roto = R.from_rotvec(((1, 0, 0),))
+    for _ in range(10):
+        bg2.rotate(roto)
+
+    np.testing.assert_allclose(
+        bg.position,
+        bg2.position,
+        err_msg="attach p",
+    )
+    np.testing.assert_allclose(
+        bg.orientation.as_quat(),
+        bg2.orientation.as_quat(),
+        err_msg="attach o",
+    )
+
+
+def test_path_functionality1():
+    """testing path functionality in detail"""
+    pos0 = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5.0]])
+    rot0 = R.from_quat(
+        [(1, 0, 0, 1), (2, 0, 0, 1), (4, 0, 0, 1), (5, 0, 0, 1), (10, 0, 0, 1.0)]
+    )
+    inpath = np.array([(0.1, 0.1, 0.1), (0.2, 0.2, 0.2), (0.3, 0.3, 0.3)])
+
+    b1, b2, b3, b4, b5 = pos0
+    c1, c2, c3 = inpath
+    q1, q2, q3, q4, q5 = np.array(
+        [(1, 0, 0, 1), (1, 0, 0, 0.5), (1, 0, 0, 0.25), (1, 0, 0, 0.2), (1, 0, 0, 0.1)]
+    )
+
+    pos, ori = evall(BaseGeo(pos0, rot0))
+    P = np.array([b1, b2, b3, b4, b5])
+    Q = np.array([q1, q2, q3, q4, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=0))
+    P = np.array([b1 + c1, b2 + c2, b3 + c3, b4, b5])
+    Q = np.array([q1, q2, q3, q4, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=1))
+    P = np.array([b1, b2 + c1, b3 + c2, b4 + c3, b5])
+    Q = np.array([q1, q2, q3, q4, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=2))
+    P = np.array([b1, b2, b3 + c1, b4 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+
+def test_path_functionality2():
+    """testing path functionality in detail"""
+    pos0 = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5.0]])
+    rot0 = R.from_quat(
+        [(1, 0, 0, 1), (2, 0, 0, 1), (4, 0, 0, 1), (5, 0, 0, 1), (10, 0, 0, 1.0)]
+    )
+    inpath = np.array([(0.1, 0.1, 0.1), (0.2, 0.2, 0.2), (0.3, 0.3, 0.3)])
+
+    b1, b2, b3, b4, b5 = pos0
+    c1, c2, c3 = inpath
+    q1, q2, q3, q4, q5 = np.array(
+        [(1, 0, 0, 1), (1, 0, 0, 0.5), (1, 0, 0, 0.25), (1, 0, 0, 0.2), (1, 0, 0, 0.1)]
+    )
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=3))
+    P = np.array([b1, b2, b3, b4 + c1, b5 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=4))
+    P = np.array([b1, b2, b3, b4, b5 + c1, b5 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5, q5, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=5))
+    P = np.array([b1, b2, b3, b4, b5, b5 + c1, b5 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5, q5, q5, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath, start=5))
+    P = np.array([b1, b2, b3, b4, b5, b5 + c1, b5 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5, q5, q5, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+    pos, ori = evall(BaseGeo(pos0, rot0).move(inpath))
+    P = np.array([b1, b2, b3, b4, b5, b5 + c1, b5 + c2, b5 + c3])
+    Q = np.array([q1, q2, q3, q4, q5, q5, q5, q5])
+    np.testing.assert_allclose(pos, P)
+    np.testing.assert_allclose(ori, Q)
+
+
+def test_path_functionality3():
+    """testing path functionality in detail"""
+    pos0 = np.array([[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5.0]])
+    rot0 = R.from_quat(
+        [(1, 0, 0, 1), (2, 0, 0, 1), (4, 0, 0, 1), (5, 0, 0, 1), (10, 0, 0, 1.0)]
+    )
+    inpath = np.array([(0.1, 0.1, 0.1), (0.2, 0.2, 0.2), (0.3, 0.3, 0.3)])
+
+    pos1, ori1 = evall(BaseGeo(pos0, rot0).move(inpath, start=4))
+    pos2, ori2 = evall(BaseGeo(pos0, rot0).move(inpath, start=-1))
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+    pos1, ori1 = evall(BaseGeo(pos0, rot0).move(inpath, start=3))
+    pos2, ori2 = evall(BaseGeo(pos0, rot0).move(inpath, start=-2))
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+    pos1, ori1 = evall(BaseGeo(pos0, rot0).move(inpath, start=2))
+    pos2, ori2 = evall(BaseGeo(pos0, rot0).move(inpath, start=-3))
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+    pos1, ori1 = evall(BaseGeo(pos0, rot0).move(inpath, start=1))
+    pos2, ori2 = evall(BaseGeo(pos0, rot0).move(inpath, start=-4))
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+    pos1, ori1 = evall(BaseGeo(pos0, rot0).move(inpath, start=0))
+    pos2, ori2 = evall(BaseGeo(pos0, rot0).move(inpath, start=-5))
+    np.testing.assert_allclose(pos1, pos2)
+    np.testing.assert_allclose(ori1, ori2)
+
+
+def test_scipy_from_methods():
+    """test all rotation methods inspired from scipy implemented in BaseTransform"""
+
+    def cube():
+        return magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 1, 1))
+
+    angs_deg = np.linspace(0, 360, 10)
+    angs = np.deg2rad(angs_deg)
+    rot = R.from_rotvec((np.array([[0, 0, 1]] * 10).T * angs).T)
+    anchor = (1, 2, 3)
+    cube0 = cube().rotate(rot, anchor=anchor)
+
+    from_rotvec = cube().rotate_from_rotvec(
+        rot.as_rotvec(degrees=True), anchor=anchor, degrees=True
+    )
+    np.testing.assert_allclose(
+        cube0.position,
+        from_rotvec.position,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="from_rotvec failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_rotvec.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+    from_angax = cube().rotate_from_angax(angs_deg, "z", anchor=anchor, degrees=True)
+    np.testing.assert_allclose(
+        cube0.position,
+        from_angax.position,
+        err_msg="from_angax failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_angax.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+    from_euler = cube().rotate_from_euler(angs_deg, "z", anchor=anchor, degrees=True)
+    np.testing.assert_allclose(
+        cube0.position,
+        from_euler.position,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="from_euler failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_euler.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+    from_matrix = cube().rotate_from_matrix(rot.as_matrix(), anchor=anchor)
+    np.testing.assert_allclose(
+        cube0.position,
+        from_matrix.position,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="from_matrix failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_matrix.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+    from_mrp = cube().rotate_from_mrp(rot.as_mrp(), anchor=anchor)
+    np.testing.assert_allclose(
+        cube0.position,
+        from_mrp.position,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="from_mrp failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_mrp.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+    from_quat = cube().rotate_from_quat(rot.as_quat(), anchor=anchor)
+    np.testing.assert_allclose(
+        cube0.position,
+        from_quat.position,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="from_quat failed on position",
+    )
+    np.testing.assert_allclose(
+        cube0.orientation.as_rotvec(),
+        from_quat.orientation.as_rotvec(),
+        err_msg="from_rotvec failed on orientation",
+    )
+
+
+def test_style():
+    """test when setting wrong style class"""
+    bg = BaseGeo((0, 0, 0), None)
+    bg.style = {"color": "red"}
+    bg.style = {"label": "mylabel"}
+    assert bg.style.color == "red"
+    assert bg.style.label == "mylabel"
+    with pytest.raises(ValueError, match="Input parameter `style` must be.*"):
+        bg.style = "wrong class"
+
+
+def test_kwargs():
+    """test kwargs inputs, only relevant for styles"""
+    bg = BaseGeo((0, 0, 0), None, style={"label": "label_01"}, style_label="label_02")
+    assert bg.style.label == "label_02"
+
+    with pytest.raises(TypeError):
+        BaseGeo((0, 0, 0), None, styl_label="label_02")
+
+
+def test_copy():
+    """test copying object"""
+    bg1 = BaseGeo((0, 0, 0), None, style_label="label1")  # has style
+    bg2 = BaseGeo((1, 2, 3), None)  # has no style
+    bg3 = BaseGeo((4, 6, 8), style_color="blue")  # has style but label is None
+    bg1c = bg1.copy()
+    bg2c = bg2.copy(position=(10, 0, 0), style={"color": "red"}, style_color="orange")
+    bg3c = bg3.copy()
+
+    # original object should not be affected"
+    np.testing.assert_allclose(bg1.position, (0, 0, 0))
+    np.testing.assert_allclose(bg2.position, (1, 2, 3))
+
+    # check if label suffix iterated correctly
+    assert bg1c.style.label == "label2"
+    assert bg2c.style.label is None
+    assert bg3c.style.label == "BaseGeo_01"
+
+    # check if style is passed correctly
+    assert bg2c.style.color == "orange"
+
+
+def test_copy_parents():
+    """make sure that parents are not copied"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+
+    c = x1 + x2 + x3
+
+    y = x1.copy()
+
+    assert x1.parent.parent == c
+    assert y.parent is None
+
+
+def test_copy_order():
+    """Make sure copying objects of a collection does not affect order of children (#530)"""
+
+    thing1 = magpy.magnet.Cuboid(style_label="t1")
+    thing2 = magpy.magnet.Cuboid(style_label="t2")
+    thing3 = magpy.magnet.Cuboid(style_label="t3")
+    coll = magpy.Collection(thing1, thing2, thing3)
+
+    desc_before = coll.describe(format="label", return_string=True)
+
+    thing1.copy()
+
+    desc_after = coll.describe(format="label", return_string=True)
+
+    assert desc_after == desc_before
+
+
+def match_string_up_to_id(s1, s2, /):
+    """Checks if 2 inputs (first is list, second string) match as long as id= follows a
+    series of numbers"""
+    patt = "id=[0-9]*[0-9]", "id=Regex"
+    assert re.sub(*patt, "\n".join(s1)).split("\n") == re.sub(*patt, s2).split("\n")
+
+
+def test_describe_with_label():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    # pylint: disable=protected-access
+    x = magpy.magnet.Cuboid(style_label="x1")
+
+    # describe calls print by default -> no return value
+    desc = x.describe()
+    assert desc is None
+
+    # describe string
+    test = [
+        "Cuboid(id=2743358534352, label='x1')",
+        "  • parent: None",
+        "  • position: [0. 0. 0.] m",
+        "  • orientation: [0. 0. 0.] deg",
+        "  • dimension: None m",
+        "  • magnetization: None A/m",
+        "  • polarization: None T",
+    ]
+    match_string_up_to_id(test, x.describe(return_string=True))
+
+    # describe html string
+    test_html = ("<pre>" + "\n".join(test) + "</pre>").split("\n")
+    match_string_up_to_id(test_html, x._repr_html_().replace("<br>", "\n"))
+
+
+def test_describe_with_parent():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    x = magpy.magnet.Cuboid(style_label="x1")
+    magpy.Collection(x)  # add parent
+    test = [
+        "Cuboid(id=1687262797456, label='x1')",
+        "  • parent: Collection(id=1687262859280)",
+        "  • position: [0. 0. 0.] m",
+        "  • orientation: [0. 0. 0.] deg",
+        "  • dimension: None m",
+        "  • magnetization: None A/m",
+        "  • polarization: None T",
+    ]
+    match_string_up_to_id(test, x.describe(return_string=True))
+
+
+def test_describe_with_path():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    x = magpy.Sensor(position=[(1, 2, 3)] * 3)
+    test = [
+        "Sensor(id=2743359152656)",
+        "  • parent: None",
+        "  • path length: 3",
+        "  • position (last): [1. 2. 3.] m",
+        "  • orientation (last): [0. 0. 0.] deg",
+        "  • handedness: right",
+        "  • pixel: None",
+    ]
+    match_string_up_to_id(test, x.describe(return_string=True))
+
+
+def test_describe_with_exclude_None():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    x = magpy.Sensor()
+    test = [
+        "Sensor(id=1687262758416)",
+        "  • parent: None",
+        "  • position: [0. 0. 0.] m",
+        "  • orientation: [0. 0. 0.] deg",
+        "  • handedness: right",
+        "  • pixel: None",
+        (
+            "  • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True),"
+            " y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True)),"
+            " color=None, description=Description(show=None, text=None), label=None,"
+            " legend=Legend(show=None, text=None), model3d=Model3d(data=[], showdefault=True),"
+            " opacity=None, path=Path(frames=None, line=Line(color=None, style=None, width=None),"
+            " marker=Marker(color=None, size=None, symbol=None), numbering=None, show=None),"
+            " pixel=Pixel(color=None, size=1, sizemode=None, symbol=None), size=None,"
+            " sizemode=None)"
+        ),
+    ]
+    match_string_up_to_id(test, x.describe(exclude=None, return_string=True))
+
+
+def test_describe_with_many_pixels():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    x = magpy.Sensor(pixel=[[[(1, 2, 3)] * 5] * 5] * 3, handedness="left")
+    test = [
+        "Sensor(id=1687262996944)",
+        "  • parent: None",
+        "  • position: [0. 0. 0.] m",
+        "  • orientation: [0. 0. 0.] deg",
+        "  • handedness: left",
+        "  • pixel: 75 (3x5x5)",
+    ]
+    match_string_up_to_id(test, x.describe(return_string=True))
+
+
+def test_describe_with_triangularmesh():
+    """testing describe method"""
+    # print("test = [\n    " + '",\n    '.join(f'"{s}' for s in desc.split("\n")) + '",\n]')
+    points = [
+        (-1, -1, 0),
+        (-1, 1, 0),
+        (1, -1, 0),
+        (1, 1, 0),
+        (0, 0, 2),
+    ]
+    x = magpy.magnet.TriangularMesh.from_ConvexHull(
+        polarization=(0, 0, 1),
+        points=points,
+        check_selfintersecting="skip",
+    )
+    test = [
+        "TriangularMesh(id=1687257413648)",
+        "  • parent: None",
+        "  • position: [0. 0. 0.] m",
+        "  • orientation: [0. 0. 0.] deg",
+        "  • magnetization: [     0.              0.         795774.71545948] A/m",
+        "  • polarization: [0. 0. 1.] T",
+        "  • barycenter: [0.         0.         0.46065534]",
+        "  • faces: shape(6, 3)",
+        "  • mesh: shape(6, 3, 3)",
+        "  • status_disconnected: False",
+        "  • status_disconnected_data: 1 part",
+        "  • status_open: False",
+        "  • status_open_data: []",
+        "  • status_reoriented: True",
+        "  • status_selfintersecting: None",
+        "  • status_selfintersecting_data: None",
+        "  • vertices: shape(5, 3)",
+    ]
+
+    match_string_up_to_id(test, x.describe(return_string=True))
+
+
+def test_unset_describe():
+    """test describe completely unset objects"""
+    objs = [
+        magpy.magnet.Cuboid(),
+        magpy.magnet.Cylinder(),
+        magpy.magnet.CylinderSegment(),
+        magpy.magnet.Sphere(),
+        magpy.magnet.Tetrahedron(),
+        # magpy.magnet.TriangularMesh(), not possible yet
+        magpy.misc.Triangle(),
+        magpy.misc.Dipole(),
+        magpy.current.Polyline(),
+        magpy.current.Circle(),
+    ]
+
+    for o in objs:
+        o.describe()
diff --git a/tests/test_obj_BaseGeo_v4motion.py b/tests/test_obj_BaseGeo_v4motion.py
new file mode 100644
index 000000000..2d6bea5c6
--- /dev/null
+++ b/tests/test_obj_BaseGeo_v4motion.py
@@ -0,0 +1,383 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+
+# pylint: disable=too-many-positional-arguments
+
+###############################################################################
+###############################################################################
+# NEW BASE GEO TESTS FROM v4
+
+
+def validate_pos_orient(obj, ppath, opath_as_rotvec):
+    """test position (ppath) and orientation (opath) of BaseGeo object (obj)"""
+    sp = obj.position
+    so = obj.orientation
+    ppath = np.array(ppath)
+    opath = R.from_rotvec(opath_as_rotvec)
+    np.testing.assert_allclose(
+        sp,
+        ppath,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg=f"position validation failed with ({sp})\n expected {ppath}",
+    )
+    np.testing.assert_allclose(
+        so.as_matrix(),
+        opath.as_matrix(),
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg=(
+            f"orientation validation failed with ({so.as_rotvec()})"
+            f"\n expected {opath_as_rotvec}"
+        ),
+    )
+
+
+###############################################################################
+###############################################################################
+# BASEGEO POS/ORI INIT TESTING
+# at initialization position and orientation must have similar shape (N,3)
+# - if inputs are (N,3) and (3,) then the (3,) is tiled up to (N,3)
+# - if inputs are (N,3) and (M,3) then the smaller one is padded up
+# - None orientation input is interpreted as (0,0,0) rotvec == (0,0,0,1) quat
+
+
+def get_init_pos_orient_test_data():
+    """
+    returns data for object init testing
+
+    init_position, init_orientation_rotvec, expected_position, expected_orientation_rotvec
+    """
+    p0 = (1, 2, 3)
+    p1 = [(1, 2, 3)]
+    p2 = [(1, 2, 3), (1, 1, 1)]
+    o0 = None
+    o1 = (0, 0, 0.1)
+    o2 = [(0, 0, 0.1)]
+    o3 = [(0, 0, 0.1), (0, 0, 0.2)]
+    o4 = [(0, 0, 0.1), (0, 0, 0.2), (0, 0, 0.3)]
+
+    return [
+        [p0, o0, p0, (0, 0, 0)],
+        [p0, o1, p0, o1],
+        [p0, o2, p0, o1],
+        [p0, o3, (p0, p0), o3],
+        [p1, o0, p0, (0, 0, 0)],
+        [p1, o1, p0, o1],
+        [p1, o2, p0, o1],
+        [p1, o3, (p0, p0), o3],
+        [p2, o0, p2, [(0, 0, 0)] * 2],
+        [p2, o1, p2, [o1] * 2],
+        [p2, o2, p2, [o1] * 2],
+        [p2, o3, p2, o3],
+        [p2, o4, [*p2, (1, 1, 1)], o4],  # uneven paths
+    ]
+
+
+@pytest.mark.parametrize(
+    (
+        "init_position",
+        "init_orientation_rotvec",
+        "expected_position",
+        "expected_orientation_rotvec",
+    ),
+    get_init_pos_orient_test_data(),
+    ids=[f"{ind + 1:02d}" for ind, t in enumerate(get_init_pos_orient_test_data())],
+)
+def test_BaseGeo_init(
+    init_position,
+    init_orientation_rotvec,
+    expected_position,
+    expected_orientation_rotvec,
+):
+    """test position and orientation initialization"""
+    # print(init_position, init_orientation_rotvec, expected_position, expected_orientation_rotvec)
+    if init_orientation_rotvec is None:
+        init_orientation_rotvec = (0, 0, 0)
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=init_position,
+        orientation=R.from_rotvec(init_orientation_rotvec),
+    )
+    validate_pos_orient(src, expected_position, expected_orientation_rotvec)
+
+
+############################################################################
+############################################################################
+# BASEGEO POS/ORI SETTER TESTING
+# when pos/ori is set then ori/pos is edge-padded / end-sliced to similar shape.
+
+
+def get_data_object_setter(inp):
+    """
+    returns data for object setter tests
+
+    init_pos, init_ori, test_pos, test_ori
+    """
+    # test positions
+    p1 = (1, 2, 3)
+    p3 = [(2, 3, 4), (3, 4, 5), (4, 5, 6)]
+
+    # test orientations
+    o1 = (0.1, 0.2, 0.3)
+    o3 = [(0.1, 0.2, 0.3), (0.2, 0.3, 0.4), (0.3, 0.4, 0.5)]
+
+    # init states
+    P1 = (1, 1, 1)
+    O1 = (0.1, 0.1, 0.1)
+    P2 = [(2, 2, 2), (3, 3, 3)]
+    O2 = [(0.2, 0.2, 0.2), (0.3, 0.3, 0.3)]
+    P3 = [(2, 2, 2), (3, 3, 3), (4, 4, 4)]
+    O3 = [(0.2, 0.2, 0.2), (0.3, 0.3, 0.3), (0.4, 0.4, 0.4)]
+    P4 = [(2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5)]
+    O4 = [(0.2, 0.2, 0.2), (0.3, 0.3, 0.3), (0.4, 0.4, 0.4), (0.5, 0.5, 0.5)]
+
+    test_data_pos = [
+        # position init, orientation init, set/test position, test orientation
+        (P1, O1, p1, O1),
+        (P1, O1, p3, [O1] * 3),  # edge-pad
+        (P2, O2, p1, O2[1]),  # end-slice
+        (P2, O2, p3, [*O2, (0.3, 0.3, 0.3)]),  # edge-pad
+        (P3, O3, p1, O3[2]),  # end-slice
+        (P3, O3, p3, O3),
+        (P4, O4, p1, O4[3]),  # end-slice
+        (P4, O4, p3, O4[1:]),  # end-slice
+    ]
+
+    test_data_ori = [
+        # position init, orientation init, set/test position, test orientation
+        (P1, O1, P1, o1),
+        (P1, O1, [P1] * 3, o3),  # edge-pad
+        (P2, O2, P2[1], o1),  # end-slice
+        (P2, O2, [*P2, P2[1]], o3),  # edge-pad
+        (P3, O3, P3[-1], o1),  # end-slice
+        (P3, O3, P3, o3),
+        (P4, O4, P4[-1], o1),  # end-slice
+        (P4, O4, P4[1:], o3),  # end-slice
+    ]
+    if inp == "pos":
+        return test_data_pos
+    return test_data_ori
+
+
+@pytest.mark.parametrize(
+    ("init_pos", "init_ori", "test_pos", "test_ori"),
+    get_data_object_setter("pos"),
+    ids=[f"{ind + 1:02d}" for ind, _ in enumerate(get_data_object_setter("pos"))],
+)
+def test_BaseGeo_setting_position(
+    init_pos,
+    init_ori,
+    test_pos,
+    test_ori,
+):
+    """test position and orientation initialization"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=init_pos,
+        orientation=R.from_rotvec(init_ori),
+    )
+    src.position = test_pos
+    validate_pos_orient(src, test_pos, test_ori)
+
+
+@pytest.mark.parametrize(
+    ("init_pos", "init_ori", "test_pos", "test_ori"),
+    get_data_object_setter("ori"),
+    ids=[f"{ind + 1:02d}" for ind, _ in enumerate(get_data_object_setter("ori"))],
+)
+def test_BaseGeo_setting_orientation(
+    init_pos,
+    init_ori,
+    test_pos,
+    test_ori,
+):
+    """test position and orientation initialization"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=init_pos,
+        orientation=R.from_rotvec(init_ori),
+    )
+    src.orientation = R.from_rotvec(test_ori)
+    validate_pos_orient(src, test_pos, test_ori)
+
+
+###############################################################################
+###############################################################################
+# BASEGEO MULTI-ANCHOR ROTATION TESTING
+
+
+def get_data_BaseGeo_multianchor_rotation():
+    """get test data as dictionaries for multi anchor testing"""
+    return [
+        {
+            "description": "scalar path - scalar anchor",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": 90,
+            "axis": "z",
+            "anchor": 0,
+            "start": "auto",
+            "expected_position": (0, 0, 0),
+            "expected_orientation_rotvec": (0, 0, np.pi / 2),
+        },
+        {
+            "description": "vector path 1 - scalar anchor",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": [90],
+            "axis": "z",
+            "anchor": (1, 0, 0),
+            "start": "auto",
+            "expected_position": [(0, 0, 0), (1, -1, 0)],
+            "expected_orientation_rotvec": [(0, 0, 0), (0, 0, np.pi / 2)],
+        },
+        {
+            "description": "vector path 2 - scalar anchor",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": [90, 270],
+            "axis": "z",
+            "anchor": (1, 0, 0),
+            "start": "auto",
+            "expected_position": [(0, 0, 0), (1, -1, 0), (1, 1, 0)],
+            "expected_orientation_rotvec": [
+                (0, 0, 0),
+                (0, 0, np.pi / 2),
+                (0, 0, -np.pi / 2),
+            ],
+        },
+        {
+            "description": "scalar path - vector anchor 1",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": 90,
+            "axis": "z",
+            "anchor": [(1, 0, 0)],
+            "start": "auto",
+            "expected_position": [(0, 0, 0), (1, -1, 0)],
+            "expected_orientation_rotvec": [(0, 0, 0), (0, 0, np.pi / 2)],
+        },
+        {
+            "description": "scalar path - vector anchor 2",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": 90,
+            "axis": "z",
+            "anchor": [(1, 0, 0), (2, 0, 0)],
+            "start": "auto",
+            "expected_position": [(0, 0, 0), (1, -1, 0), (2, -2, 0)],
+            "expected_orientation_rotvec": [
+                (0, 0, 0),
+                (0, 0, np.pi / 2),
+                (0, 0, np.pi / 2),
+            ],
+        },
+        {
+            "description": "vector path 2 - vector anchor 2",
+            "init_position": (0, 0, 0),
+            "init_orientation_rotvec": None,
+            "angle": [90, 270],
+            "axis": "z",
+            "anchor": [(1, 0, 0), (2, 0, 0)],
+            "start": "auto",
+            "expected_position": [(0, 0, 0), (1, -1, 0), (2, 2, 0)],
+            "expected_orientation_rotvec": [
+                (0, 0, 0),
+                (0, 0, np.pi / 2),
+                (0, 0, -np.pi / 2),
+            ],
+        },
+        {
+            "description": "vector path 2 - vector anchor 2 - path 2 - start=0",
+            "init_position": [(0, 0, 0), (2, 1, 0)],
+            "init_orientation_rotvec": None,
+            "angle": [90, 270],
+            "axis": "z",
+            "anchor": [(1, 0, 0), (2, 0, 0)],
+            "start": 0,
+            "expected_position": [(1, -1, 0), (3, 0, 0)],
+            "expected_orientation_rotvec": [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+        },
+        {
+            "description": "init crazy, path 2, anchor 3, start middle",
+            "init_position": [(0, 0, 0), (2, 0, 0)],
+            "init_orientation_rotvec": (0, 0, 0.1),
+            "angle": [90, 270],
+            "axis": "z",
+            "anchor": [(1, 0, 0), (2, 0, 0), (3, 0, 0)],
+            "start": 1,
+            "expected_position": [(0, 0, 0), (1, 1, 0), (2, 0, 0), (3, 1, 0)],
+            "expected_orientation_rotvec": [
+                (0, 0, 0.1),
+                (0, 0, np.pi / 2 + 0.1),
+                (0, 0, -np.pi / 2 + 0.1),
+                (0, 0, -np.pi / 2 + 0.1),
+            ],
+        },
+        {
+            "description": "init crazy, path 2, anchor 3, start before",
+            "init_position": [(0, 0, 0), (2, 0, 0)],
+            "init_orientation_rotvec": (0, 0, 0.1),
+            "angle": [90, 270],
+            "axis": "z",
+            "anchor": [(1, 0, 0), (2, 0, 0), (3, 0, 0)],
+            "start": -4,
+            "expected_position": [(1, -1, 0), (2, 2, 0), (3, 3, 0), (2, 0, 0)],
+            "expected_orientation_rotvec": [
+                (0, 0, 0.1 + np.pi / 2),
+                (0, 0, 0.1 - np.pi / 2),
+                (0, 0, 0.1 - np.pi / 2),
+                (0, 0, 0.1),
+            ],
+        },
+    ]
+
+
+@pytest.mark.parametrize(
+    (
+        "description",
+        "init_position",
+        "init_orientation_rotvec",
+        "angle",
+        "axis",
+        "anchor",
+        "start",
+        "expected_position",
+        "expected_orientation_rotvec",
+    ),
+    [d.values() for d in get_data_BaseGeo_multianchor_rotation()],
+    ids=[d["description"] for d in get_data_BaseGeo_multianchor_rotation()],
+)
+def test_BaseGeo_multianchor_rotation(
+    description,
+    init_position,
+    init_orientation_rotvec,
+    angle,
+    axis,
+    anchor,
+    start,
+    expected_position,
+    expected_orientation_rotvec,
+):
+    """testing BaseGeo multi anchor rotations"""
+    print(description)
+    # print(locals())
+    if init_orientation_rotvec is None:
+        init_orientation_rotvec = (0, 0, 0)
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=init_position,
+        orientation=R.from_rotvec(init_orientation_rotvec),
+    )
+    src.rotate_from_angax(angle, axis, anchor, start)
+    validate_pos_orient(src, expected_position, expected_orientation_rotvec)
diff --git a/tests/test_obj_Circle.py b/tests/test_obj_Circle.py
new file mode 100644
index 000000000..f89ba5e32
--- /dev/null
+++ b/tests/test_obj_Circle.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibDeprecationWarning
+
+
+def test_Circle_basic_B():
+    """Basic Circle class test"""
+    src = magpy.current.Circle(current=123, diameter=2)
+    sens = magpy.Sensor(position=(1, 2, 3))
+
+    B = src.getB(sens)
+    Btest = np.array([0.44179833, 0.88359665, 0.71546231]) * 1e-6
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_BHJM_circle():
+    """test explicit field output values"""
+    s = magpy.current.Circle(current=1, diameter=1)
+
+    B_c1d1z0 = 1.2566370614359172 * 1e-6
+    B_test = s.getB([0, 0, 0])
+    assert abs(B_c1d1z0 - B_test[2]) < 1e-14
+
+    B_c1d1z1 = 0.11239703569665165 * 1e-6
+    B_test = s.getB([0, 0, 1])
+    assert abs(B_c1d1z1 - B_test[2]) < 1e-14
+
+    s = magpy.current.Circle(current=1, diameter=2)
+    B_c1d2z0 = 0.6283185307179586 * 1e-6
+    B_test = s.getB([0, 0, 0])
+    assert abs(B_c1d2z0 - B_test[2]) < 1e-14
+
+    B_c1d2z1 = 0.22214414690791835 * 1e-6
+    B_test = s.getB([0, 0, 1])
+    assert abs(B_c1d2z1 - B_test[2]) < 1e-14
+
+
+def test_Circle_basic_H():
+    """Basic Circle class test"""
+    src = magpy.current.Circle(current=123, diameter=2)
+    sens = magpy.Sensor(position=(1, 2, 3))
+
+    H = src.getH(sens)
+    Htest = np.array([0.44179833, 0.88359665, 0.71546231]) * 10 / 4 / np.pi
+    np.testing.assert_allclose(H, Htest)
+
+
+# def test_Circular_problem_positions():
+#     """ Circle on z and on loop
+#     """
+#     src = magpy.current.Circle(current=1, diameter=2)
+#     sens = magpy.Sensor()
+#     sens.move([[0,1,0],[1,0,0]], start=1)
+
+#     B = src.getB(sens)
+#     Btest = np.array([[0,0,0.6283185307179586], [0,0,0], [0,0,0]])
+#     np.testing.assert_allclose(B, Btest)
+
+
+def test_repr():
+    """test __repr__"""
+    dip = magpy.current.Circle(current=1, diameter=1)
+    assert repr(dip)[:6] == "Circle", "Circle repr failed"
+
+
+def test_old_Loop_deprecation_warning():
+    """test old class deprecation warning"""
+    with pytest.warns(MagpylibDeprecationWarning):
+        old_class = magpy.current.Loop()
+
+    new_class = magpy.current.Circle()
+    assert isinstance(old_class, magpy.current.Circle)
+    assert isinstance(new_class, magpy.current.Circle)
diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py
new file mode 100644
index 000000000..5cd0a6ffd
--- /dev/null
+++ b/tests/test_obj_Collection.py
@@ -0,0 +1,472 @@
+from __future__ import annotations
+
+import pickle
+import re
+from pathlib import Path
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+# pylint: disable=unnecessary-lambda
+# pylint: disable=unnecessary-lambda-assignment
+# pylint: disable=no-member
+
+# # # GENERATE TESTDATA
+# # N = 5
+# # mags = (np.random.rand(N,6,3)-0.5)*1000
+# # dims3 = np.random.rand(N,3,3)*5     # 5x cuboid
+# # dims2 = np.random.rand(N,3,2)*5     # 5x cylinder
+# # posos = (np.random.rand(N,23,3)-0.5)*10 #readout at 333 positions
+
+# # angs =  (np.random.rand(N,18)-0.5)*2*10 # each step rote by max 10 deg
+# # axs =   (np.random.rand(N,18,3)-0.5)
+# # anchs = (np.random.rand(N,18,3)-0.5)*5.5
+# # movs =  (np.random.rand(N,18,3)-0.5)*0.5
+# # rvs = (np.random.rand(N,3)-.5)*0.1
+
+# # B = []
+# # for mag,dim2,dim3,ang,ax,anch,mov,poso,rv in zip(
+# #        mags,dims2,dims3,angs,axs,anchs,movs,posos,rvs):
+# #     rot = R.from_rotvec(rv)
+# #     pm1b = magpy.magnet.Cuboid(mag[0],dim3[0])
+# #     pm2b = magpy.magnet.Cuboid(mag[1],dim3[1])
+# #     pm3b = magpy.magnet.Cuboid(mag[2],dim3[2])
+# #     pm4b = magpy.magnet.Cylinder(mag[3],dim2[0])
+# #     pm5b = magpy.magnet.Cylinder(mag[4],dim2[1])
+# #     pm6b = magpy.magnet.Cylinder(mag[5],dim2[2])
+
+# #     # 18 subsequent operations
+# #     for a,aa,aaa,mv in zip(ang,ax,anch,mov):
+# #         for pm in [pm1b,pm2b,pm3b,pm4b,pm5b,pm6b]:
+# #             pm.move(mv).rotate_from_angax(a,aa,aaa).rotate(rot,aaa)
+# #     B += [magpy.getB([pm1b,pm2b,pm3b,pm4b,pm5b,pm6b], poso, sumup=True)]
+# # B = np.array(B)
+# # inp = [mags,dims2,dims3,posos,angs,axs,anchs,movs,rvs,B]
+# # pickle.dump(inp,open('testdata_Collection.p', 'wb'))
+
+
+def test_Collection_basics():
+    """test Collection fundamentals, test against magpylib2 fields"""
+    # pylint: disable=pointless-statement
+    # data generated below
+    with Path("tests/testdata/testdata_Collection.p").resolve().open("rb") as f:
+        data = pickle.load(f)
+    mags, dims2, dims3, posos, angs, axs, anchs, movs, rvs, _ = data
+
+    B1, B2 = [], []
+    for mag, dim2, dim3, ang, ax, anch, mov, poso, rv in zip(
+        mags, dims2, dims3, angs, axs, anchs, movs, posos, rvs, strict=False
+    ):
+        rot = R.from_rotvec(rv)
+
+        pm1b = magpy.magnet.Cuboid(polarization=mag[0], dimension=dim3[0])
+        pm2b = magpy.magnet.Cuboid(polarization=mag[1], dimension=dim3[1])
+        pm3b = magpy.magnet.Cuboid(polarization=mag[2], dimension=dim3[2])
+        pm4b = magpy.magnet.Cylinder(polarization=mag[3], dimension=dim2[0])
+        pm5b = magpy.magnet.Cylinder(polarization=mag[4], dimension=dim2[1])
+        pm6b = magpy.magnet.Cylinder(polarization=mag[5], dimension=dim2[2])
+
+        pm1 = magpy.magnet.Cuboid(polarization=mag[0], dimension=dim3[0])
+        pm2 = magpy.magnet.Cuboid(polarization=mag[1], dimension=dim3[1])
+        pm3 = magpy.magnet.Cuboid(polarization=mag[2], dimension=dim3[2])
+        pm4 = magpy.magnet.Cylinder(polarization=mag[3], dimension=dim2[0])
+        pm5 = magpy.magnet.Cylinder(polarization=mag[4], dimension=dim2[1])
+        pm6 = magpy.magnet.Cylinder(polarization=mag[5], dimension=dim2[2])
+
+        col1 = magpy.Collection(pm1, pm2, pm3)
+        col1.add(pm4, pm5, pm6)
+
+        # 18 subsequent operations
+        for a, aa, aaa, mv in zip(ang, ax, anch, mov, strict=False):
+            for pm in [pm1b, pm2b, pm3b, pm4b, pm5b, pm6b]:
+                pm.move(mv).rotate_from_angax(a, aa, aaa).rotate(rot, aaa)
+
+            col1.move(mv).rotate_from_angax(a, aa, aaa, start=-1).rotate(
+                rot, aaa, start=-1
+            )
+
+        B1 += [magpy.getB([pm1b, pm2b, pm3b, pm4b, pm5b, pm6b], poso, sumup=True)]
+        B2 += [col1.getB(poso)]
+
+    B1 = np.array(B1)
+    B2 = np.array(B2)
+
+    np.testing.assert_allclose(B1, B2)
+
+
+@pytest.mark.parametrize(
+    ("test_input", "expected"),
+    [
+        ("sens_col.getB(src_col).shape", (4, 3)),
+        ("src_col.getB(sens_col).shape", (4, 3)),
+        ("mixed_col.getB().shape", (4, 3)),
+        ("sens_col.getB(src1, src2).shape", (2, 4, 3)),
+        ("src_col.getB(sens1,sens2,sens3,sens4).shape", (4, 3)),
+        ("src1.getB(sens_col).shape", (4, 3)),
+        ("sens1.getB(src_col).shape", (3,)),
+        ("sens1.getB(mixed_col).shape", (3,)),
+        ("src1.getB(mixed_col).shape", (4, 3)),
+        ("src_col.getB(mixed_col).shape", (4, 3)),
+        ("sens_col.getB(mixed_col).shape", (4, 3)),
+        ("magpy.getB([src1, src2], [sens1,sens2,sens3,sens4]).shape", (2, 4, 3)),
+        ("magpy.getB(mixed_col,mixed_col).shape", (4, 3)),
+        ("magpy.getB([src1, src2], [[1,2,3],(2,3,4)]).shape", (2, 2, 3)),
+        ("src_col.getB([[1,2,3],(2,3,4)]).shape", (2, 3)),
+        ("src_col.getB([1,2,3]).shape", (3,)),
+        ("src1.getB(np.array([1,2,3])).shape", (3,)),
+    ],
+)
+def test_col_getB(test_input, expected):
+    """testing some Collection stuff with getB"""
+    src1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 1), dimension=(1, 1, 1), position=(0, 0, 0)
+    )
+    src2 = magpy.magnet.Cylinder(
+        polarization=(0, 1, 0), dimension=(1, 1), position=(-1, 0, 0)
+    )
+    sens1 = magpy.Sensor(position=(0, 0, 1))
+    sens2 = magpy.Sensor(position=(0, 0, 1))
+    sens3 = magpy.Sensor(position=(0, 0, 1))
+    sens4 = magpy.Sensor(position=(0, 0, 1))
+
+    sens_col = sens1 + sens2 + sens3 + sens4
+    src_col = src1 + src2
+    mixed_col = sens_col + src_col
+    variables = {
+        "np": np,
+        "magpy": magpy,
+        "src1": src1,
+        "src2": src2,
+        "sens1": sens1,
+        "sens2": sens2,
+        "sens3": sens3,
+        "sens4": sens4,
+        "sens_col": sens_col,
+        "src_col": src_col,
+        "mixed_col": mixed_col,
+    }
+
+    # pylint: disable=eval-used
+    assert eval(test_input, variables) == expected
+
+
+@pytest.mark.parametrize(
+    "test_input",
+    [
+        "src1.getB()",
+        "src1.getB(src1)",
+        "magpy.getB(src1,src1)",
+        "src1.getB(src_col)",
+        "magpy.getB(src1,src_col)",
+        "sens1.getB()",
+        "magpy.getB(sens1,src1)",
+        "sens1.getB(sens1)",
+        "magpy.getB(sens1,sens1)",
+        "magpy.getB(sens1,mixed_col)",
+        "magpy.getB(sens1,src_col)",
+        "sens1.getB(sens_col)",
+        "magpy.getB(sens1,sens_col)",
+        "mixed_col.getB(src1)",
+        "magpy.getB(mixed_col,src1)",
+        "mixed_col.getB(sens1)",
+        "mixed_col.getB(mixed_col)",
+        "mixed_col.getB(src_col)",
+        "magpy.getB(mixed_col,src_col)",
+        "mixed_col.getB(sens_col)",
+        "src_col.getB()",
+        "src_col.getB(src1)",
+        "magpy.getB(src_col,src1)",
+        "src_col.getB(src_col)",
+        "magpy.getB(src_col,src_col)",
+        "sens_col.getB()",
+        "magpy.getB(sens_col,src1)",
+        "sens_col.getB(sens1)",
+        "magpy.getB(sens_col,sens1)",
+        "magpy.getB(sens_col,mixed_col)",
+        "magpy.getB(sens_col,src_col)",
+        "sens_col.getB(sens_col)",
+        "magpy.getB(sens_col,sens_col)",
+    ],
+)
+def test_bad_col_getB_inputs(test_input):
+    """more undocumented Collection checking"""
+    # pylint: disable=eval-used
+
+    src1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 1), dimension=(8, 4, 6), position=(0, 0, 0)
+    )
+
+    src2 = magpy.magnet.Cylinder(
+        polarization=(0, 1, 0), dimension=(8, 5), position=(-15, 0, 0)
+    )
+
+    sens1 = magpy.Sensor(position=(0, 0, 6))
+    sens2 = magpy.Sensor(position=(0, 0, 6))
+    sens3 = magpy.Sensor(position=(0, 0, 6))
+    sens4 = magpy.Sensor(position=(0, 0, 6))
+
+    sens_col = sens1 + sens2 + sens3 + sens4
+    src_col = src1 + src2
+    mixed_col = sens_col + src_col
+    variables = {
+        "magpy": magpy,
+        "src1": src1,
+        "src2": src2,
+        "sens1": sens1,
+        "sens2": sens2,
+        "sens3": sens3,
+        "sens4": sens4,
+        "sens_col": sens_col,
+        "src_col": src_col,
+        "mixed_col": mixed_col,
+    }
+    with pytest.raises(MagpylibBadUserInput):
+        assert eval(test_input, variables) is not None
+
+
+def test_col_get_item():
+    """test get_item with collections"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm3 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+
+    col = magpy.Collection(pm1, pm2, pm3)
+    assert col[1] == pm2, "get_item failed"
+    assert len(col) == 3, "__len__ failed"
+
+
+def test_col_getH():
+    """test collection getH"""
+    pm1 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=3)
+    pm2 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=3)
+    col = magpy.Collection(pm1, pm2)
+    H = col.getH((0, 0, 0))
+    H1 = pm1.getH((0, 0, 0))
+    np.testing.assert_array_equal(H, 2 * H1, err_msg="col getH fail")
+
+
+def test_col_reset_path():
+    """testing display"""
+    # pylint: disable=no-member
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    col = magpy.Collection(pm1, pm2)
+    col.move([(1, 2, 3)] * 10)
+    col.reset_path()
+    assert col[0].position.ndim == 1, "col reset path fail"
+    assert col[1].position.ndim == 1, "col reset path fail"
+    assert col.position.ndim == 1, "col reset path fail"
+
+
+def test_Collection_squeeze():
+    """testing squeeze output"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    col = magpy.Collection(pm1, pm2)
+    sensor = magpy.Sensor(pixel=[(1, 2, 3), (1, 2, 3)])
+    B = col.getB(sensor)
+    assert B.shape == (2, 3)
+    H = col.getH(sensor)
+    assert H.shape == (2, 3)
+
+    B = col.getB(sensor, squeeze=False)
+    assert B.shape == (1, 1, 1, 2, 3)
+    H = col.getH(sensor, squeeze=False)
+    assert H.shape == (1, 1, 1, 2, 3)
+
+
+def test_Collection_with_Dipole():
+    """Simple test of Dipole in Collection"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3), position=(1, 2, 3))
+    col = magpy.Collection(src)
+    sens = magpy.Sensor()
+
+    B = magpy.getB(col, sens)
+    Btest = np.array([3.81801774e-09, 7.63603548e-09, 1.14540532e-08])
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_adding_sources():
+    """test if all sources can be added"""
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cylinder()
+    s3 = magpy.magnet.CylinderSegment()
+    s4 = magpy.magnet.Sphere()
+    s5 = magpy.current.Circle()
+    s6 = magpy.current.Polyline()
+    s7 = magpy.misc.Dipole()
+    x1 = magpy.Sensor()
+    c1 = magpy.Collection()
+    c2 = magpy.Collection()
+
+    for obj in [s1, s2, s3, s4, s5, s6, s7, x1, c1]:
+        c2.add(obj)
+
+    strs = ""
+    for src in c2:
+        strs += str(src)[:3]
+
+    assert strs == "CubCylCylSphCirPolDipSenCol"
+
+
+def test_set_children_styles():
+    """test if styles get applied"""
+    src1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src2 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    col = src1 + src2
+    col.set_children_styles(magnetization_show=False)
+    assert src1.style.magnetization.show is False, "failed updating styles to src1"
+    assert src2.style.magnetization.show is False, "failed updating styles to src2"
+    with pytest.raises(
+        ValueError,
+        match=r"Following arguments are invalid style properties: `{'bad_input'}`",
+    ):
+        col.set_children_styles(bad_input="somevalue")
+
+
+def test_reprs():
+    """test repr strings"""
+    # pylint: disable=protected-access
+
+    c = magpy.Collection()
+    assert repr(c)[:10] == "Collection"
+
+    s1 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=5)
+    c = magpy.Collection(s1)
+    assert repr(c)[:10] == "Collection"
+
+    x1 = magpy.Sensor()
+    c = magpy.Collection(x1)
+    assert repr(c)[:10] == "Collection"
+
+    x1 = magpy.Sensor()
+    s1 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=5)
+    c = magpy.Collection(s1, x1)
+    assert repr(c)[:10] == "Collection"
+
+    x1 = magpy.magnet.Cuboid(style_label="x1")
+    x2 = magpy.magnet.Cuboid(style_label="x2")
+    cc = x1 + x2
+    rep = cc._repr_html_()
+    rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep)
+    test = "<pre>Collection nolabel (id=REGEX)<br>├── Cuboid x1"
+    test += " (id=REGEX)<br>└── Cuboid x2 (id=REGEX)</pre>"
+    assert rep == test
+
+
+def test_collection_describe():
+    """test describe method"""
+
+    x = magpy.magnet.Cuboid(style_label="x")
+    y = magpy.magnet.Cuboid(style_label="y")
+    z = magpy.magnet.Cuboid(style_label="z")
+    u = magpy.magnet.Cuboid(style_label="u")
+    c = x + y + z + u
+
+    desc = c.describe(format="label, type", return_string=True).split("\n")
+    test = [
+        "Collection nolabel",
+        "├── Collection nolabel",
+        "│   ├── Collection nolabel",
+        "│   │   ├── Cuboid x",
+        "│   │   └── Cuboid y",
+        "│   └── Cuboid z",
+        "└── Cuboid u",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="label", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "├── Collection",
+        "│   ├── Collection",
+        "│   │   ├── x",
+        "│   │   └── y",
+        "│   └── z",
+        "└── u",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="type", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "├── Collection",
+        "│   ├── Collection",
+        "│   │   ├── Cuboid",
+        "│   │   └── Cuboid",
+        "│   └── Cuboid",
+        "└── Cuboid",
+    ]
+    assert test == desc
+
+    desc = c.describe(format="label,type,id", return_string=True).split("\n")
+    test = [
+        "Collection nolabel (id=REGEX)",
+        "├── Collection nolabel (id=REGEX)",
+        "│   ├── Collection nolabel (id=REGEX)",
+        "│   │   ├── Cuboid x (id=REGEX)",
+        "│   │   └── Cuboid y (id=REGEX)",
+        "│   └── Cuboid z (id=REGEX)",
+        "└── Cuboid u (id=REGEX)",
+    ]
+    assert "".join(test) == re.sub("id=*[0-9]*[0-9]", "id=REGEX", "".join(desc))
+
+    c = magpy.Collection(*[magpy.magnet.Cuboid() for _ in range(100)])
+    c.add(*[magpy.current.Circle() for _ in range(50)])
+    c.add(*[magpy.misc.CustomSource() for _ in range(25)])
+
+    desc = c.describe(format="type+label", return_string=True).split("\n")
+    test = [
+        "Collection nolabel",
+        "├── 100x Cuboids",
+        "├── 50x Circles",
+        "└── 25x CustomSources",
+    ]
+    assert test == desc
+
+    x = magpy.magnet.Cuboid(style_label="x")
+    y = magpy.magnet.Cuboid(style_label="y")
+    cc = x + y
+    desc = cc.describe(format="label, properties", return_string=True).split("\n")
+    test = [
+        "Collection",
+        "│   • position: [0. 0. 0.] m",
+        "│   • orientation: [0. 0. 0.] deg",
+        "├── x",
+        "│       • position: [0. 0. 0.] m",
+        "│       • orientation: [0. 0. 0.] deg",
+        "│       • dimension: None m",
+        "│       • magnetization: None A/m",
+        "│       • polarization: None T",
+        "└── y",
+        "        • position: [0. 0. 0.] m",
+        "        • orientation: [0. 0. 0.] deg",
+        "        • dimension: None m",
+        "        • magnetization: None A/m",
+        "        • polarization: None T",
+    ]
+    assert "".join(test) == re.sub("id=*[0-9]*[0-9]", "id=REGEX", "".join(desc))
+
+    desc = cc.describe()
+    assert desc is None
+
+
+def test_col_getBH_input_format():
+    """
+    Collections should produce the same BHJM shapes as individual
+    sources.
+    """
+    cube = magpy.magnet.Cuboid(
+        polarization=(0, 0, 1),
+        dimension=(2, 2, 2),
+    )
+    coll = magpy.Collection(cube)
+
+    for obs in [(0, 0, 0), [(0, 0, 0)], [[(0, 0, 0)]]]:
+        shape1 = cube.getB(obs, squeeze=False).shape
+        shape2 = coll.getB(obs, squeeze=False).shape
+        assert np.all(shape1 == shape2)
diff --git a/tests/test_obj_Collection_child_parent.py b/tests/test_obj_Collection_child_parent.py
new file mode 100644
index 000000000..5c793a111
--- /dev/null
+++ b/tests/test_obj_Collection_child_parent.py
@@ -0,0 +1,368 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+# pylint: disable=unnecessary-lambda-assignment
+# pylint: disable=no-member
+
+
+def test_parent_setter():
+    """setting and removing a parent"""
+
+    def child_labels(x):
+        return [c.style.label for c in x]
+
+    # default parent is None
+    x1 = magpy.Sensor(style_label="x1")
+    assert x1.parent is None
+
+    # init collection gives parent
+    c1 = magpy.Collection(x1, style_label="c1")
+    assert x1.parent.style.label == "c1"
+    assert child_labels(c1) == ["x1"]
+
+    # remove parent with setter
+    x1.parent = None
+    assert x1.parent is None
+    assert child_labels(c1) == []
+
+    # set parent
+    x1.parent = c1
+    assert x1.parent.style.label == "c1"
+    assert child_labels(c1) == ["x1"]
+
+    # set another parent
+    c2 = magpy.Collection(style_label="c2")
+    x1.parent = c2
+    assert x1.parent.style.label == "c2"
+    assert child_labels(c1) == []
+    assert child_labels(c2) == ["x1"]
+
+
+def test_children_setter():
+    """setting new children and removing old parents"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+    x4 = magpy.Sensor()
+
+    c = magpy.Collection(x1, x2)
+    c.children = [x3, x4]
+
+    # remove old parents
+    assert x1.parent is None
+    assert x2.parent is None
+    # new children
+    assert c[0] == x3
+    assert c[1] == x4
+
+
+def test_setter_parent_override():
+    """all setter should override parents"""
+    x1 = magpy.Sensor()
+    s1 = magpy.magnet.Cuboid()
+    c1 = magpy.Collection()
+    coll = magpy.Collection(x1, s1, c1)
+
+    coll2 = magpy.Collection()
+    coll2.children = coll.children
+    assert coll2.children == [x1, s1, c1]
+
+    coll3 = magpy.Collection()
+    coll3.sensors = [x1]
+    coll3.sources = [s1]
+    coll3.collections = [c1]
+    assert coll3.children == [x1, s1, c1]
+
+
+def test_sensors_setter():
+    """setting new sensors and removing old parents"""
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+    x4 = magpy.Sensor()
+    s1 = magpy.magnet.CylinderSegment()
+
+    c = magpy.Collection(x1, x2, s1)
+    c.sensors = [x3, x4]
+
+    # remove old parents
+    assert x1.parent is None
+    assert x2.parent is None
+    # keep non-sensors
+    assert s1.parent == c
+    # new sensors
+    assert c[0] == s1
+    assert c.sensors[0] == x3
+    assert c.sensors[1] == x4
+
+
+def test_sources_setter():
+    """setting new sources and removing old parents"""
+    s1 = magpy.magnet.Cylinder()
+    s2 = magpy.magnet.Cylinder()
+    s3 = magpy.magnet.Cylinder()
+    s4 = magpy.magnet.Cylinder()
+    x1 = magpy.Sensor()
+
+    c = magpy.Collection(x1, s1, s2)
+    c.sources = [s3, s4]
+
+    # old parents
+    assert s1.parent is None
+    assert s2.parent is None
+    # keep non-sources
+    assert x1.parent == c
+    # new children
+    assert c[0] == x1
+    assert c.sources[0] == s3
+    assert c[2] == s4
+
+
+def test_collections_setter():
+    """setting new sources and removing old parents"""
+    c1 = magpy.Collection()
+    c2 = magpy.Collection()
+    c3 = magpy.Collection()
+    c4 = magpy.Collection()
+    x1 = magpy.Sensor()
+
+    c = magpy.Collection(c1, x1, c2)
+    c.collections = [c3, c4]
+
+    # old parents
+    assert c1.parent is None
+    assert c2.parent is None
+    # keep non-collections
+    assert x1.parent == c
+    # new children
+    assert c[0] == x1
+    assert c.collections[0] == c3
+    assert c[2] == c4
+
+
+def test_collection_inputs():
+    """test basic collection inputs"""
+
+    s1 = magpy.magnet.Cuboid(style_label="s1")
+    s2 = magpy.magnet.Cuboid(style_label="s2")
+    s3 = magpy.magnet.Cuboid(style_label="s3")
+    x1 = magpy.Sensor(style_label="x1")
+    x2 = magpy.Sensor(style_label="x2")
+    c1 = magpy.Collection(x2, style_label="c1")
+
+    c2 = magpy.Collection(c1, x1, s1, s2, s3)
+    assert [c.style.label for c in c2.children] == ["c1", "x1", "s1", "s2", "s3"]
+    assert [c.style.label for c in c2.sensors] == ["x1"]
+    assert [c.style.label for c in c2.sources] == ["s1", "s2", "s3"]
+    assert [c.style.label for c in c2.collections] == ["c1"]
+
+
+def test_collection_parent_child_relation():
+    """test if parent-child relations are properly set with collections"""
+
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cuboid()
+    s3 = magpy.magnet.Cuboid()
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    c1 = magpy.Collection(x2)
+    c2 = magpy.Collection(c1, x1, s1, s2, s3)
+
+    assert x1.parent == c2
+    assert s3.parent == c2
+    assert x2.parent == c1
+    assert c1.parent == c2
+    assert c2.parent is None
+
+
+def test_collections_add():
+    """test collection construction"""
+
+    def child_labels(x):
+        return [c.style.label for c in x]
+
+    x1 = magpy.Sensor(style_label="x1")
+    x2 = magpy.Sensor(style_label="x2")
+    x3 = magpy.Sensor(style_label="x3")
+    x6 = magpy.Sensor(style_label="x6")
+    x7 = magpy.Sensor(style_label="x7")
+
+    # simple add
+    c2 = magpy.Collection(x1, style_label="c2")
+    c2.add(x2, x3)
+    assert child_labels(c2) == ["x1", "x2", "x3"]
+
+    # adding another collection
+    c3 = magpy.Collection(x6, style_label="c3")
+    c2.add(c3)
+    assert child_labels(c2) == ["x1", "x2", "x3", "c3"]
+    assert child_labels(c3) == ["x6"]
+
+    # adding to child collection should not change its parent collection
+    c3.add(x7)
+    assert child_labels(c2) == ["x1", "x2", "x3", "c3"]
+    assert child_labels(c3) == ["x6", "x7"]
+
+    # add with parent override
+    assert x7.parent == c3
+
+    c4 = magpy.Collection(style_label="c4")
+    c4.add(x7, override_parent=True)
+
+    assert child_labels(c3) == ["x6"]
+    assert child_labels(c4) == ["x7"]
+    assert x7.parent == c4
+
+    # set itself as parent should fail
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        c2.parent = c2
+
+    # add itself, also nested, should fail
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        c2.add(magpy.Collection(c2))
+
+
+def test_collection_plus():
+    """
+    testing collection adding and the += functionality
+    """
+
+    def child_labels(x):
+        return [c.style.label for c in x]
+
+    s1 = magpy.magnet.Cuboid(style_label="s1")
+    s2 = magpy.magnet.Cuboid(style_label="s2")
+    x1 = magpy.Sensor(style_label="x1")
+    x2 = magpy.Sensor(style_label="x2")
+    x3 = magpy.Sensor(style_label="x3")
+    c1 = magpy.Collection(s1, style_label="c1")
+
+    # practical simple +
+    c2 = c1 + s2
+    assert child_labels(c2) == ["c1", "s2"]
+
+    # useless triple addition consistency
+    c3 = x1 + x2 + x3
+    assert c3[0][0].style.label == "x1"
+    assert c3[0][1].style.label == "x2"
+    assert c3[1].style.label == "x3"
+
+    # useless += consistency
+    s3 = magpy.magnet.Cuboid(style_label="s3")
+    c2 += s3
+    assert [c.style.label for c in c2[0]] == ["c1", "s2"]
+    assert c2[1] == s3
+
+
+def test_collection_remove():
+    """removing from collections"""
+
+    def child_labels(x):
+        return [c.style.label for c in x]
+
+    def source_labels(x):
+        return [c.style.label for c in x.sources]
+
+    def sensor_labels(x):
+        return [c.style.label for c in x.sensors]
+
+    x1 = magpy.Sensor(style_label="x1")
+    x2 = magpy.Sensor(style_label="x2")
+    x3 = magpy.Sensor(style_label="x3")
+    x4 = magpy.Sensor(style_label="x4")
+    x5 = magpy.Sensor(style_label="x5")
+    s1 = magpy.misc.Dipole(style_label="s1")
+    s2 = magpy.misc.Dipole(style_label="s2")
+    s3 = magpy.misc.Dipole(style_label="s3")
+    q1 = magpy.misc.CustomSource(style_label="q1")
+    c1 = magpy.Collection(x1, x2, x3, x4, x5, style_label="c1")
+    c2 = magpy.Collection(s1, s2, s3, style_label="c2")
+    c3 = magpy.Collection(q1, c1, c2, style_label="c3")
+
+    assert child_labels(c1) == ["x1", "x2", "x3", "x4", "x5"]
+    assert child_labels(c2) == ["s1", "s2", "s3"]
+    assert child_labels(c3) == ["q1", "c1", "c2"]
+
+    # remove item from collection
+    c1.remove(x5)
+    assert child_labels(c1) == ["x1", "x2", "x3", "x4"]
+    assert [c.style.label for c in c1.sensors] == ["x1", "x2", "x3", "x4"]
+
+    # remove 2 items from collection
+    c1.remove(x3, x4)
+    assert child_labels(c1) == ["x1", "x2"]
+    assert sensor_labels(c1) == ["x1", "x2"]
+
+    # remove item from child collection
+    c3.remove(s3)
+    assert child_labels(c3) == ["q1", "c1", "c2"]
+    assert child_labels(c2) == ["s1", "s2"]
+    assert source_labels(c2) == ["s1", "s2"]
+
+    # remove child collection
+    c3.remove(c2)
+    assert child_labels(c3) == ["q1", "c1"]
+    assert child_labels(c2) == ["s1", "s2"]
+
+    # attempt remove non-existent child
+    c3.remove(s1, errors="ignore")
+    assert child_labels(c3) == ["q1", "c1"]
+    assert child_labels(c1) == ["x1", "x2"]
+
+    # attempt remove child in lower level with recursion=False
+    c3.remove(x1, errors="ignore", recursive=False)
+    assert child_labels(c3) == ["q1", "c1"]
+    assert child_labels(c1) == ["x1", "x2"]
+
+    # attempt remove of non-existing child
+    with np.testing.assert_raises(MagpylibBadUserInput):
+        c3.remove(x1, errors="raise", recursive=False)
+
+
+def test_collection_nested_getBH():
+    """test if getBH functionality is self-consistent with nesting"""
+    s1 = magpy.current.Circle(current=1, diameter=1)
+    s2 = magpy.current.Circle(current=1, diameter=1)
+    s3 = magpy.current.Circle(current=1, diameter=1)
+    s4 = magpy.current.Circle(current=1, diameter=1)
+
+    obs = [(1, 2, 3), (-2, -3, 1), (2, 2, -4), (4, 2, -4)]
+    coll = s1 + s2 + s3 + s4  # nasty nesting
+
+    B1 = s1.getB(obs)
+    B4 = coll.getB(obs)
+    np.testing.assert_allclose(4 * B1, B4)
+
+    H1 = s1.getH(obs)
+    H4 = coll.getH(obs)
+    np.testing.assert_allclose(4 * H1, H4)
+
+
+def test_collection_properties_all():
+    """test _all properties"""
+    s1 = magpy.magnet.Cuboid()
+    s2 = magpy.magnet.Cylinder()
+    s3 = magpy.current.Circle()
+    s4 = magpy.current.Circle()
+    x1 = magpy.Sensor()
+    x2 = magpy.Sensor()
+    x3 = magpy.Sensor()
+    c1 = magpy.Collection(s2)
+    c3 = magpy.Collection(s4)
+    c2 = magpy.Collection(s3, x3, c3)
+
+    cc = magpy.Collection(s1, x1, c1, x2, c2)
+
+    assert cc.children == [s1, x1, c1, x2, c2]
+    assert cc.sources == [s1]
+    assert cc.sensors == [x1, x2]
+    assert cc.collections == [c1, c2]
+
+    assert cc.children_all == [s1, x1, c1, s2, x2, c2, s3, x3, c3, s4]
+    assert cc.sources_all == [s1, s2, s3, s4]
+    assert cc.sensors_all == [x1, x2, x3]
+    assert cc.collections_all == [c1, c2, c3]
diff --git a/tests/test_obj_Collection_v4motion.py b/tests/test_obj_Collection_v4motion.py
new file mode 100644
index 000000000..30ae07ed9
--- /dev/null
+++ b/tests/test_obj_Collection_v4motion.py
@@ -0,0 +1,757 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from scipy.spatial.transform import Rotation as R
+
+import magpylib as magpy
+
+# pylint: disable=too-many-positional-arguments
+
+###############################################################################
+###############################################################################
+# NEW COLLECTION POS/ORI TESTS FROM v4
+
+
+def validate_pos_orient(obj, ppath, opath_as_rotvec):
+    """test position (ppath) and orientation (opath) of BaseGeo object (obj)"""
+    sp = obj.position
+    so = obj.orientation
+    ppath = np.array(ppath)
+    opath = R.from_rotvec(opath_as_rotvec)
+    assert ppath.shape == sp.shape, (
+        f"position shapes do not match\n object has {sp.shape} instead of {ppath.shape}"
+    )
+    assert opath.as_rotvec().shape == so.as_rotvec().shape, (
+        "orientation as_rotvec shapes do not match"
+        f"\n object has {so.as_rotvec().shape} instead of {opath.as_rotvec().shape}"
+    )
+    np.testing.assert_allclose(
+        sp,
+        ppath,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg=f"position validation failed with ({sp}) expected {ppath}",
+    )
+    np.testing.assert_allclose(
+        so.as_matrix(),
+        opath.as_matrix(),
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg=(
+            f"orientation validation failed with ({so.as_rotvec()})"
+            f"\n expected {opath_as_rotvec}"
+        ),
+    )
+
+
+############################################################################
+############################################################################
+# COLLECTION POS/ORI SETTER TESTING
+# when setting pos/ori of a collection, the children retain their original
+# relative position and orientation in the Collection
+
+
+def get_data_collection_position_setter():
+    """
+    returns data for collection setter tests
+    Args:
+    col_pos_init, col_ori_init, src_pos_init, src_ori_init
+    col_pos_test, col_ori_test, src_pos_test, src_ori_test
+    """
+    return [
+        [
+            (1, 2, 3),
+            (0.1, 0.2, 0.3),
+            (1, 1, 1),
+            (0, 0, -0.1),
+            (3, 2, 1),
+            (0.1, 0.2, 0.3),
+            (3, 1, -1),
+            (0, 0, -0.1),
+        ],
+        [
+            [(1, 2, 3), (2, 3, 4)],
+            [(0, 0, 0)] * 2,
+            [(1, 1, 1), (2, 2, 2)],
+            [(0.1, 0.1, 0.1), (0.2, 0.2, 0.2)],
+            (4, 5, 6),
+            (0, 0, 0),
+            (4, 4, 4),
+            (0.2, 0.2, 0.2),
+        ],
+        [
+            [(1, 2, 3), (2, 3, 4)],
+            [(0, 0, 0)] * 2,
+            [(1, 1, 1), (2, 2, 2)],
+            [(0.1, 0.1, 0.1), (0.2, 0.2, 0.2)],
+            [(4, 5, 6), (5, 6, 7), (6, 7, 8)],
+            [(0, 0, 0)] * 3,
+            [(4, 4, 4), (5, 5, 5), (6, 6, 6)],
+            [(0.1, 0.1, 0.1), (0.2, 0.2, 0.2), (0.2, 0.2, 0.2)],
+        ],
+        [
+            (1, 2, 3),
+            (0, 0, 0),
+            [(1, 1, 1), (2, 2, 2)],
+            [(0.1, 0.1, 0.1)],
+            [(4, 5, 6), (5, 6, 7), (6, 7, 8)],
+            [(0, 0, 0)] * 3,
+            [(4, 4, 4), (6, 6, 6), (7, 7, 7)],
+            [(0.1, 0.1, 0.1)] * 3,
+        ],
+    ]
+
+
+@pytest.mark.parametrize(
+    (
+        "col_pos_init",
+        "col_ori_init",
+        "src_pos_init",
+        "src_ori_init",
+        "col_pos_test",
+        "col_ori_test",
+        "src_pos_test",
+        "src_ori_test",
+    ),
+    get_data_collection_position_setter(),
+    ids=[
+        f"{ind + 1:02d}" for ind, _ in enumerate(get_data_collection_position_setter())
+    ],
+)
+def test_Collection_setting_position(
+    col_pos_init,
+    col_ori_init,
+    src_pos_init,
+    src_ori_init,
+    col_pos_test,
+    col_ori_test,
+    src_pos_test,
+    src_ori_test,
+):
+    """Test position and orientation setters on Collection"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=src_pos_init,
+        orientation=R.from_rotvec(src_ori_init),
+    )
+    col = magpy.Collection(
+        src, position=col_pos_init, orientation=R.from_rotvec(col_ori_init)
+    )
+    col.position = col_pos_test
+    validate_pos_orient(col, col_pos_test, col_ori_test)
+    validate_pos_orient(src, src_pos_test, src_ori_test)
+
+
+def get_data_collection_orientation_setter():
+    """
+    returns data for collection setter tests
+    Args:
+    col_pos_init, col_ori_init, src_pos_init, src_ori_init
+    col_pos_test, col_ori_test, src_pos_test, src_ori_test
+    """
+    return [
+        # col orientation setter simple
+        [
+            (1, 0, 3),
+            (0, 0, np.pi / 4),
+            (2, 0, 1),
+            (0, 0, 0.1),
+            (1, 0, 3),
+            (0, 0, -np.pi / 4),
+            (1, -1, 1),
+            (0, 0, -np.pi / 2 + 0.1),
+        ],
+        # collection orientation setter with path
+        [
+            [(1, 0, 3), (2, 0, 3)],
+            [(0, 0, 0)] * 2,
+            [(2, 0, 1), (1, 0, 1)],
+            [(0, 0, 0)] * 2,
+            [(1, 0, 3), (2, 0, 3)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+            [(1, 1, 1), (2, 1, 1)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+        ],
+        # collection orientation setter slice test
+        [
+            [(1, 0, 3), (2, 0, 3), (3, 0, 3)],
+            [(0, 0, 0)] * 3,
+            [(2, 0, 1), (1, 0, 1), (0, 0, 1)],
+            (0, 0, 0),
+            [(2, 0, 3), (3, 0, 3)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+            [(2, -1, 1), (3, 3, 1)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+        ],
+        # collection orientation setter pad test
+        [
+            (3, 0, 3),
+            (0, 0, 0),
+            (0, 0, 1),
+            (0, 0, 0),
+            [(3, 0, 3)] * 2,
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+            [(3, -3, 1), (3, 3, 1)],
+            [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+        ],
+        # crazy collection test with different path formats
+        [
+            [(0, 0, 0), (-1, 0, 0)],
+            [(0, 0, 0)] * 2,
+            (0, 0, 0),
+            (0, 0, 0.1),
+            (-1, 0, 0),
+            (0, 0, np.pi / 2),
+            (-1, 1, 0),
+            (0, 0, np.pi / 2 + 0.1),
+        ],
+        # crazy collection test with different path formats pt2
+        [
+            [(0, 0, 0), (-1, 0, 0)],
+            [(0, 0, 0)] * 2,
+            [(1, 0, 0), (2, 0, 0), (3, 0, 0)],
+            [(0, 0, 0)] * 3,
+            (-1, 0, 0),
+            (0, 0, np.pi / 2),
+            (-1, 4, 0),
+            (0, 0, np.pi / 2),
+        ],
+    ]
+
+
+@pytest.mark.parametrize(
+    (
+        "col_pos_init",
+        "col_ori_init",
+        "src_pos_init",
+        "src_ori_init",
+        "col_pos_test",
+        "col_ori_test",
+        "src_pos_test",
+        "src_ori_test",
+    ),
+    get_data_collection_orientation_setter(),
+    ids=[
+        f"{ind + 1:02d}"
+        for ind, _ in enumerate(get_data_collection_orientation_setter())
+    ],
+)
+def test_Collection_setting_orientation(
+    col_pos_init,
+    col_ori_init,
+    src_pos_init,
+    src_ori_init,
+    col_pos_test,
+    col_ori_test,
+    src_pos_test,
+    src_ori_test,
+):
+    """test_Collection_setting_orientation"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=src_pos_init,
+        orientation=R.from_rotvec(src_ori_init),
+    )
+    col = magpy.Collection(
+        src, position=col_pos_init, orientation=R.from_rotvec(col_ori_init)
+    )
+    col.orientation = R.from_rotvec(col_ori_test)
+    validate_pos_orient(col, col_pos_test, col_ori_test)
+    validate_pos_orient(src, src_pos_test, src_ori_test)
+
+
+def test_Collection_setter():
+    """
+    general col position and orientation setter testing
+    """
+    # graphical test: is the Collection moving/rotating as a whole ?
+    # col0 = magpy.Collection()
+    # for poz,roz in zip(
+    #     [(0,0,0), (0,0,5), (5,0,0), (5,0,5), (10,0,0), (10,0,5)],
+    #     [(0,0,0), (1,0,0), (0,1,0), (0,0,1), (1,2,3), (-2,-1,3)]
+    #     ):
+    #     col = magpy.Collection()
+    #     for i,color in enumerate(['r', 'orange', 'gold', 'green', 'cyan']):
+    #         src = magpy.magnet.Cuboid(
+    #             polarization=(1,0,0),
+    #             dimension=(.5,.5,.5),
+    #             position=(1,0,0),
+    #             style_color=color
+    #         )
+    #         src.rotate_from_angax(72*i, 'z', (0,0,0))
+    #         col = col + src
+    #     base = magpy.Sensor()
+    #     col.position = poz
+    #     col.orientation = R.from_rotvec(roz)
+    #     base.position = poz
+    #     base.orientation = R.from_rotvec(roz)
+    #     col0 = col0 + col + base
+    # magpy.show(*col0)
+    POS = []
+    ORI = []
+    for poz, roz in zip(
+        [(0, 0, 0), (0, 0, 5), (5, 0, 0), (5, 0, 5), (10, 0, 0), (10, 0, 5)],
+        [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 2, 3), (-2, -1, 3)],
+        strict=False,
+    ):
+        col = magpy.Collection()
+        for i in range(5):
+            src = magpy.magnet.Cuboid(
+                polarization=(1, 0, 0), dimension=(0.5, 0.5, 0.5), position=(1, 0, 0)
+            )
+            src.rotate_from_angax(72 * i, "z", (0, 0, 0))
+            col.add(src)
+        col.position = poz
+        col.orientation = R.from_rotvec(roz)
+
+        POS += [[src.position for src in col]]
+        ORI += [[src.orientation.as_rotvec() for src in col]]
+
+    test_POS, test_ORI = np.load("tests/testdata/testdata_Collection_setter.npy")
+
+    np.testing.assert_allclose(POS, test_POS)
+    np.testing.assert_allclose(ORI, test_ORI)
+
+
+############################################################################
+############################################################################
+# COLLECTION MOTION TESTS
+# An operation move() or rotate() applied to a Collection is
+# individually applied to BaseGeo and to each child:
+
+
+def test_compound_motion_00():
+    """init Collection should not change source pos and ori"""
+    src = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 2, 3), (2, 3, 4)]
+    )
+    validate_pos_orient(src, [(1, 2, 3), (2, 3, 4)], [(0, 0, 0)] * 2)
+    col = magpy.Collection(src, position=[(1, 1, 1)])
+    validate_pos_orient(src, [(1, 2, 3), (2, 3, 4)], [(0, 0, 0)] * 2)
+    print(col)
+
+
+def test_compound_motion_01():
+    """very sensible Compound behavior with rotation anchor"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 1, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(-1, -1, -1)
+    )
+    col = magpy.Collection(s1, s2)
+    col.move((0, 0, 1))
+    validate_pos_orient(s1, (1, 1, 2), (0, 0, 0))
+    validate_pos_orient(s2, (-1, -1, 0), (0, 0, 0))
+    validate_pos_orient(col, (0, 0, 1), (0, 0, 0))
+    col.move([(0, 0, 1)])
+    validate_pos_orient(s1, [(1, 1, 2), (1, 1, 3)], [(0, 0, 0)] * 2)
+    validate_pos_orient(s2, [(-1, -1, 0), (-1, -1, 1)], [(0, 0, 0)] * 2)
+    validate_pos_orient(col, [(0, 0, 1), (0, 0, 2)], [(0, 0, 0)] * 2)
+    col.rotate_from_rotvec((0, 0, np.pi / 2), anchor=0, degrees=False)
+    validate_pos_orient(s1, [(-1, 1, 2), (-1, 1, 3)], [(0, 0, np.pi / 2)] * 2)
+    validate_pos_orient(s2, [(1, -1, 0), (1, -1, 1)], [(0, 0, np.pi / 2)] * 2)
+    validate_pos_orient(col, [(0, 0, 1), (0, 0, 2)], [(0, 0, np.pi / 2)] * 2)
+
+
+def test_compound_motion_02():
+    """very sensible Compound behavior with vector anchor"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(-1, 0, -1)
+    )
+    col = magpy.Collection(s1, s2, position=(3, 0, 3))
+    col.rotate_from_rotvec(
+        (0, 0, np.pi / 2), anchor=[(1, 0, 0), (2, 0, 0)], degrees=False
+    )
+    validate_pos_orient(
+        s1,
+        [(1, 0, 1), (1, 0, 1), (2, -1, 1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        s2,
+        [(-1, 0, -1), (1, -2, -1), (2, -3, -1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(3, 0, 3), (1, 2, 3), (2, 1, 3)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+    )
+
+
+def test_compound_motion_03():
+    """very sensible Compound behavior with vector path and anchor and start=0"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(3, 0, 0), (1, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        position=[(2, 0, 2), (2, 0, 2)],
+        orientation=R.from_rotvec([(0, 0, -0.1), (0, 0, -0.2)]),
+    )
+    col = magpy.Collection(s1, s2, position=[(3, 0, 2), (3, 0, 3)])
+    col.rotate_from_rotvec(
+        [(0, 0, np.pi / 2), (0, 0, 3 * np.pi / 2)],
+        anchor=[(1, 0, 0), (2, 0, 0)],
+        start=0,
+        degrees=False,
+    )
+    validate_pos_orient(
+        s1, [(1, 2, 0), (2, 1, 0)], [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)]
+    )
+    validate_pos_orient(
+        s2, [(1, 1, 2), (2, 0, 2)], [(0, 0, np.pi / 2 - 0.1), (0, 0, -np.pi / 2 - 0.2)]
+    )
+    validate_pos_orient(
+        col, [(1, 2, 2), (2, -1, 3)], [(0, 0, np.pi / 2), (0, 0, -np.pi / 2)]
+    )
+
+
+def test_compound_motion_04():
+    """nonsensical but correct Collection behavior when col and children
+    all have different path formats"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 1, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        orientation=R.from_rotvec([(0, 0, -0.1), (0, 0, -0.2)]),
+    )
+    col = magpy.Collection(s1, s2, position=[(1, 2, 3), (1, 3, 4)])
+    col.rotate_from_angax(90, "z", anchor=(1, 0, 0))
+    validate_pos_orient(s1, (0, 0, 1), (0, 0, np.pi / 2))
+    validate_pos_orient(
+        s2, [(1, -1, 0)] * 2, [(0, 0, np.pi / 2 - 0.1), (0, 0, np.pi / 2 - 0.2)]
+    )
+    validate_pos_orient(col, [(-1, 0, 3), (-2, 0, 4)], [(0, 0, np.pi / 2)] * 2)
+
+
+def test_compound_motion_05():
+    """nonsensical but correct Collection behavior with vector anchor"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        orientation=R.from_rotvec([(0, 0, -0.1), (0, 0, -0.2)]),
+    )
+    col = magpy.Collection(s1, s2, position=[(3, 0, 3), (4, 0, 4)])
+    col.rotate_from_angax(90, "z", anchor=[(1, 0, 0), (2, 0, 0)])
+    validate_pos_orient(
+        s1,
+        [(1, 0, 1), (1, 0, 1), (2, -1, 1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        s2,
+        [(0, 0, 0), (0, 0, 0), (1, -1, 0), (2, -2, 0)],
+        [(0, 0, -0.1), (0, 0, -0.2), (0, 0, np.pi / 2 - 0.2), (0, 0, np.pi / 2 - 0.2)],
+    )
+    validate_pos_orient(
+        col,
+        [(3, 0, 3), (4, 0, 4), (1, 3, 4), (2, 2, 4)],
+        [(0, 0, 0), (0, 0, 0), (0, 0, np.pi / 2), (0, 0, np.pi / 2)],
+    )
+
+
+def test_compound_motion_06():
+    """Compound rotation (anchor=None), scalar input, scalar pos"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, -1, -1)
+    )
+    col = magpy.Collection(s1, s2)
+    col.rotate_from_angax(90, "z")
+    validate_pos_orient(s1, (0, 1, 1), (0, 0, np.pi / 2))
+    validate_pos_orient(s2, (1, 0, -1), (0, 0, np.pi / 2))
+    validate_pos_orient(col, (0, 0, 0), (0, 0, np.pi / 2))
+
+
+def test_compound_motion_07():
+    """Compound rotation (anchor=None), scalar input, vector pos, start=auto"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 0, 0), (2, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=((0, 0, 0), (1, 0, 0)))
+    col.rotate_from_angax(90, "z")
+    validate_pos_orient(
+        s1, [(0, 1, 0), (1, 1, 0)], [(0, 0, np.pi / 2), (0, 0, np.pi / 2)]
+    )
+    validate_pos_orient(
+        s2, [(0, -1, 0), (1, -3, 0)], [(0, 0, np.pi / 2), (0, 0, np.pi / 2)]
+    )
+    validate_pos_orient(
+        col, [(0, 0, 0), (1, 0, 0)], [(0, 0, np.pi / 2), (0, 0, np.pi / 2)]
+    )
+
+
+def test_compound_motion_08():
+    """Compound rotation (anchor=None), scalar input, vector pos, start=1"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 0, 0), (2, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=((0, 0, 0), (1, 0, 0)))
+    col.rotate_from_angax(90, "z", start=1)
+    validate_pos_orient(s1, [(1, 0, 0), (1, 1, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+    validate_pos_orient(s2, [(-1, 0, 0), (1, -3, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+    validate_pos_orient(col, [(0, 0, 0), (1, 0, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+
+
+def test_compound_motion_09():
+    """Compound rotation (anchor=None), scalar input, vector pos, start=-1"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 0, 0), (2, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=((0, 0, 0), (1, 0, 0)))
+    col.rotate_from_angax(90, "z", start=-1)
+    validate_pos_orient(s1, [(1, 0, 0), (1, 1, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+    validate_pos_orient(s2, [(-1, 0, 0), (1, -3, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+    validate_pos_orient(col, [(0, 0, 0), (1, 0, 0)], [(0, 0, 0), (0, 0, np.pi / 2)])
+
+
+def test_compound_motion_10():
+    """Compound rotation (anchor=None), scalar input, vector pos, start->pad before"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 0, 0), (2, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=((2, 0, 0), (1, 0, 0)))
+    col.rotate_from_angax(90, "z", start=-4)
+    validate_pos_orient(
+        s1,
+        [(2, -1, 0), (2, -1, 0), (2, -1, 0), (1, 1, 0)],
+        [(0, 0, np.pi / 2)] * 4,
+    )
+    validate_pos_orient(
+        s2,
+        [(2, -3, 0), (2, -3, 0), (2, -3, 0), (1, -3, 0)],
+        [(0, 0, np.pi / 2)] * 4,
+    )
+    validate_pos_orient(
+        col,
+        [(2, 0, 0), (2, 0, 0), (2, 0, 0), (1, 0, 0)],
+        [(0, 0, np.pi / 2)] * 4,
+    )
+
+
+def test_compound_motion_11():
+    """Compound rotation (anchor=None), scalar input, vector pos, start->pad behind"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(1, 0, 0), (2, 0, 0)]
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=((2, 0, 0), (1, 0, 0)))
+    col.rotate_from_angax(90, "z", start=3)
+    validate_pos_orient(
+        s1,
+        [(1, 0, 0), (2, 0, 0), (2, 0, 0), (1, 1, 0)],
+        [(0, 0, 0)] * 3 + [(0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        s2,
+        [(-1, 0, 0), (-2, 0, 0), (-2, 0, 0), (1, -3, 0)],
+        [(0, 0, 0)] * 3 + [(0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(2, 0, 0), (1, 0, 0), (1, 0, 0), (1, 0, 0)],
+        [(0, 0, 0)] * 3 + [(0, 0, np.pi / 2)],
+    )
+
+
+def test_compound_motion_12():
+    """Compound rotation (anchor=None), vector input, simple pos, start=auto"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, -1, -1)
+    )
+    col = magpy.Collection(s1, s2)
+    col.rotate_from_angax([90, -90], "z")
+    validate_pos_orient(
+        s1,
+        [(1, 0, 1), (0, 1, 1), (0, -1, 1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+    validate_pos_orient(
+        s2,
+        [(0, -1, -1), (1, 0, -1), (-1, 0, -1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(0, 0, 0), (0, 0, 0), (0, 0, 0)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+
+
+def test_compound_motion_13():
+    """Compound rotation (anchor=None), vector input, vector pos, start=1"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, -1, -1)
+    )
+    col = magpy.Collection(s1, s2)
+    col.rotate_from_angax([90, -90], "z")
+    col.rotate_from_angax([-90, 180], "z", start=1)
+    validate_pos_orient(
+        s1, [(1, 0, 1), (1, 0, 1), (0, 1, 1)], [(0, 0, 0), (0, 0, 0), (0, 0, np.pi / 2)]
+    )
+    validate_pos_orient(
+        s2,
+        [(0, -1, -1), (0, -1, -1), (1, 0, -1)],
+        [(0, 0, 0), (0, 0, 0), (0, 0, np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(0, 0, 0), (0, 0, 0), (0, 0, 0)],
+        [(0, 0, 0), (0, 0, 0), (0, 0, np.pi / 2)],
+    )
+
+
+def test_compound_motion_14():
+    """Compound rotation (anchor=None), vector input, vector pos, start=1, pad_behind"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(0, -1, -1)
+    )
+    col = magpy.Collection(s1, s2)
+    col.rotate_from_angax([90, -90], "z")
+    col.rotate_from_angax([-90, 180], "z", start=1)
+    col.rotate_from_angax([90, 180, -90], "z", start=1)
+    validate_pos_orient(
+        s1,
+        [(1, 0, 1), (0, 1, 1), (0, -1, 1), (1, 0, 1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+    validate_pos_orient(
+        s2,
+        [(0, -1, -1), (1, 0, -1), (-1, 0, -1), (0, -1, -1)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+    validate_pos_orient(
+        col,
+        [(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+
+
+def test_compound_motion_15():
+    """Compound rotation (anchor=None), vector input, simple pos, start=-3, pad_before"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(1, 0, 1)
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(-1, 0, -1)
+    )
+    col = magpy.Collection(s1, s2, position=(2, 0, 0))
+    col.rotate_from_angax([90, -90], "z", start=-3)
+    validate_pos_orient(
+        s1,
+        [(2, -1, 1), (2, 1, 1), (1, 0, 1)],
+        [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+    validate_pos_orient(
+        s2,
+        [(2, -3, -1), (2, 3, -1), (-1, 0, -1)],
+        [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+    validate_pos_orient(
+        col,
+        [(2, 0, 0), (2, 0, 0), (2, 0, 0)],
+        [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, 0)],
+    )
+
+
+def test_compound_motion_16():
+    """Compound rotation (anchor=None), vector input, vector pos, start=-3,
+    pad_before AND pad_behind"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        orientation=R.from_rotvec([(0, 0, 0.1), (0, 0, 0.2)]),
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=[(-1, 0, 0), (-2, 0, 0)]
+    )
+    col = magpy.Collection(s1, s2, position=[(1, 0, 0), (0, 0, 0)])
+    col.rotate_from_angax([90, -90, 90, -90], "z", start=-3)
+    validate_pos_orient(
+        s1,
+        [(1, -1, 0), (1, 1, 0), (0, 0, 0), (0, 0, 0)],
+        [
+            (0, 0, 0.1 + np.pi / 2),
+            (0, 0, 0.1 - np.pi / 2),
+            (0, 0, 0.2 + np.pi / 2),
+            (0, 0, 0.2 - np.pi / 2),
+        ],
+    )
+    validate_pos_orient(
+        s2,
+        [(1, -2, 0), (1, 2, 0), (0, -2, 0), (0, 2, 0)],
+        [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(1, 0, 0), (1, 0, 0), (0, 0, 0), (0, 0, 0)],
+        [(0, 0, np.pi / 2), (0, 0, -np.pi / 2), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+
+
+def test_compound_motion_17():
+    """CRAZY Compound rotation (anchor=None) with messy path formats"""
+    s1 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0),
+        dimension=(1, 1, 1),
+        orientation=R.from_rotvec([(0, 0, 0.1), (0, 0, 0.2)]),
+    )
+    s2 = magpy.magnet.Cuboid(
+        polarization=(1, 0, 0), dimension=(1, 1, 1), position=(-1, 0, 0)
+    )
+    col = magpy.Collection(s1, s2, position=[(1, 0, 0), (0, 0, 0), (3, 0, 3)])
+    col.rotate_from_angax([90, -90], "z", start="auto")
+    validate_pos_orient(
+        s1,
+        [(0, 0, 0), (0, 0, 0), (3, -3, 0), (3, 3, 0)],
+        [(0, 0, 0.1), (0, 0, 0.2), (0, 0, 0.2 + np.pi / 2), (0, 0, 0.2 - np.pi / 2)],
+    )
+    validate_pos_orient(
+        s2,
+        [(-1, 0, 0), (3, -4, 0), (3, 4, 0)],
+        [(0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
+    validate_pos_orient(
+        col,
+        [(1, 0, 0), (0, 0, 0), (3, 0, 3), (3, 0, 3), (3, 0, 3)],
+        [(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, np.pi / 2), (0, 0, -np.pi / 2)],
+    )
diff --git a/tests/test_obj_Cuboid.py b/tests/test_obj_Cuboid.py
new file mode 100644
index 000000000..c01ff1d49
--- /dev/null
+++ b/tests/test_obj_Cuboid.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+import numpy as np
+
+import magpylib as magpy
+from magpylib._src.fields.field_BH_cuboid import BHJM_magnet_cuboid
+from magpylib._src.obj_classes.class_Sensor import Sensor
+
+# # # """data generation for test_Cuboid()"""
+
+# # N = 100
+
+# # mags  = (np.random.rand(N,3)-0.5)*1000
+# # dims  =  np.random.rand(N,3)*5
+# # posos = (np.random.rand(N,333,3)-0.5)*10 #readout at 333 positions
+
+# # angs  =  (np.random.rand(N,18)-0.5)*2*10 # each step rote by max 10 deg
+# # axs   =   (np.random.rand(N,18,3)-0.5)
+# # anchs = (np.random.rand(N,18,3)-0.5)*5.5
+# # movs  =  (np.random.rand(N,18,3)-0.5)*0.5
+
+# # B = []
+# # for mag,dim,ang,ax,anch,mov,poso in zip(mags,dims,angs,axs,anchs,movs,posos):
+# #     pm = magpy.magnet.Cuboid(mag,dim)
+
+# #     # 18 subsequent operations
+# #     for a,aa,aaa,mv in zip(ang,ax,anch,mov):
+# #         pm.move(mv).rotate_from_angax(a,aa,aaa)
+
+# #     B += [pm.getB(poso)]
+# # B = np.array(B)
+
+# # inp = [mags,dims,posos,angs,axs,anchs,movs,B]
+
+# # pickle.dump(inp, open(os.path.abspath('testdata_Cuboid.p'), 'wb'))
+
+
+def test_Cuboid_basics():
+    """test Cuboid fundamentals"""
+    # data generated in comment above
+    with Path("tests/testdata/testdata_Cuboid.p").resolve().open("rb") as f:
+        data = pickle.load(f)
+    mags, dims, posos, angs, axs, anchs, movs, B = data
+
+    btest = []
+    for mag, dim, ang, ax, anch, mov, poso in zip(
+        mags, dims, angs, axs, anchs, movs, posos, strict=False
+    ):
+        pm = magpy.magnet.Cuboid(polarization=mag, dimension=np.abs(dim))
+
+        # 18 subsequent operations
+        for a, aa, aaa, mv in zip(ang, ax, anch, mov, strict=False):
+            pm.move(mv).rotate_from_angax(a, aa, aaa, start=-1)
+
+        btest += [pm.getB(poso)]
+    btest = np.array(btest)
+
+    np.testing.assert_allclose(B, btest)
+
+
+def test_Cuboid_add():
+    """testing __add__"""
+    src1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    src2 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    col = src1 + src2
+    assert isinstance(col, magpy.Collection), "adding cuboides fail"
+
+
+def test_Cuboid_squeeze():
+    """testing squeeze output"""
+    src1 = magpy.magnet.Cuboid(polarization=(1, 1, 1), dimension=(1, 1, 1))
+    sensor = Sensor(pixel=[(1, 2, 3), (1, 2, 3)])
+    B = src1.getB(sensor)
+    assert B.shape == (2, 3)
+    H = src1.getH(sensor)
+    assert H.shape == (2, 3)
+
+    B = src1.getB(sensor, squeeze=False)
+    assert B.shape == (1, 1, 1, 2, 3)
+    H = src1.getH(sensor, squeeze=False)
+    assert H.shape == (1, 1, 1, 2, 3)
+
+
+def test_repr_cuboid():
+    """test __repr__"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm1.style.label = "cuboid_01"
+    assert repr(pm1)[:6] == "Cuboid", "Cuboid repr failed"
+    assert "label='cuboid_01'" in repr(pm1), "Cuboid repr failed"
+
+
+def test_cuboid_object_vs_lib():
+    """
+    includes a test of the input copy problem
+    """
+
+    a = 1
+    pol = np.array([(10, 20, 30)])
+    dim = np.array([(a, a, a)])
+    pos = np.array([(2 * a, 2 * a, 2 * a)])
+    B0 = BHJM_magnet_cuboid(field="B", observers=pos, polarization=pol, dimension=dim)
+    H0 = BHJM_magnet_cuboid(field="H", observers=pos, polarization=pol, dimension=dim)
+
+    src = magpy.magnet.Cuboid(polarization=pol[0], dimension=dim[0])
+    B1 = src.getB(pos)
+    H1 = src.getH(pos)
+
+    np.testing.assert_allclose(B0[0], B1)
+    np.testing.assert_allclose(H0[0], H1)
+
+
+def test_getM():
+    """getM test"""
+    m0 = (0, 0, 0)
+    m1 = (10, 200, 3000)
+    cube = magpy.magnet.Cuboid(dimension=(2, 2, 2), magnetization=m1)
+    obs = [
+        (2, 2, 2),
+        (0, 0, 0),
+        (0.5, 0.5, 0.5),
+        (3, 0, 0),
+    ]
+    sens = magpy.Sensor(pixel=obs)
+
+    M1 = cube.getM(obs)
+    M2 = magpy.getM(cube, sens)
+    M3 = sens.getM(cube)
+    Mtest = np.array([m0, m1, m1, m0])
+
+    np.testing.assert_allclose(M1, Mtest)
+    np.testing.assert_allclose(M2, Mtest)
+    np.testing.assert_allclose(M3, Mtest)
+
+
+def test_getJ():
+    """getM test"""
+    j0 = (0, 0, 0)
+    j1 = (0.1, 0.2, 0.3)
+    cube = magpy.magnet.Cuboid(
+        dimension=(2, 2, 2),
+        polarization=j1,
+    )
+    obs = [
+        (-2, 2, -2),
+        (0, 0, 0),
+        (-0.5, -0.5, 0.5),
+        (-3, 0, 0),
+    ]
+    sens = magpy.Sensor(pixel=obs)
+
+    J1 = cube.getJ(obs)
+    J2 = magpy.getJ(cube, sens)
+    J3 = sens.getJ(cube)
+
+    Jtest = np.array([j0, j1, j1, j0])
+    np.testing.assert_allclose(J1, Jtest)
+    np.testing.assert_allclose(J2, Jtest)
+    np.testing.assert_allclose(J3, Jtest)
diff --git a/tests/test_obj_Cylinder.py b/tests/test_obj_Cylinder.py
new file mode 100644
index 000000000..8bc78ead5
--- /dev/null
+++ b/tests/test_obj_Cylinder.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_Cylinder_add():
+    """testing __add__"""
+    src1 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    src2 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    col = src1 + src2
+    assert isinstance(col, magpy.Collection), "adding cylinder fail"
+
+
+def test_Cylinder_squeeze():
+    """testing squeeze output"""
+    src1 = magpy.magnet.Cylinder(polarization=(1, 1, 1), dimension=(1, 1))
+    sensor = magpy.Sensor(pixel=[(1, 2, 3), (1, 2, 3)])
+    B = src1.getB(sensor)
+    assert B.shape == (2, 3)
+    H = src1.getH(sensor)
+    assert H.shape == (2, 3)
+
+    B = src1.getB(sensor, squeeze=False)
+    assert B.shape == (1, 1, 1, 2, 3)
+    H = src1.getH(sensor, squeeze=False)
+    assert H.shape == (1, 1, 1, 2, 3)
+
+
+def test_repr():
+    """test __repr__"""
+    pm2 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(2, 3))
+    assert repr(pm2)[:8] == "Cylinder", "Cylinder repr failed"
+
+
+def test_repr2():
+    """test __repr__"""
+    pm2 = magpy.magnet.CylinderSegment(
+        polarization=(1, 2, 3), dimension=(2, 3, 1, 0, 45)
+    )
+    assert repr(pm2)[:15] == "CylinderSegment", "CylinderSegment repr failed"
+
+
+def test_Cylinder_getBH():
+    """
+    test Cylinder getB and getH with different inputs
+    vs the vectorized form
+    """
+    pol = (22, 33, 44)
+    poso = [
+        (0.123, 0.234, 0.345),
+        (-0.123, 0.234, 0.345),
+        (0.123, -0.234, 0.345),
+        (0.123, 0.234, -0.345),
+        (-0.123, -0.234, 0.345),
+        (-0.123, 0.234, -0.345),
+        (0.123, -0.234, -0.345),
+        (-0.123, -0.234, -0.345),
+        (12, 13, 14),
+        (-12, 13, 14),
+        (12, -13, 14),
+        (12, 13, -14),
+        (-12, -13, 14),
+        (12, -13, -14),
+        (-12, 13, -14),
+        (-12, -13, -14),
+    ]
+
+    dim2 = [(1, 2), (2, 3), (3, 4)]
+    dim5 = [(0, 0.5, 2, 0, 360), (0, 1, 3, 0, 360), (0.0000001, 1.5, 4, 0, 360)]
+
+    for d2, d5 in zip(dim2, dim5, strict=False):
+        src1 = magpy.magnet.Cylinder(polarization=pol, dimension=d2)
+        src2 = magpy.magnet.CylinderSegment(polarization=pol, dimension=d5)
+        B0 = src1.getB(poso)
+        H0 = src1.getH(poso)
+
+        B1 = src2.getB(poso)
+        H1 = src2.getH(poso)
+
+        B2 = magpy.getB(
+            "Cylinder",
+            poso,
+            polarization=pol,
+            dimension=d2,
+        )
+        H2 = magpy.getH(
+            "Cylinder",
+            poso,
+            polarization=pol,
+            dimension=d2,
+        )
+
+        B3 = magpy.getB(
+            "CylinderSegment",
+            poso,
+            polarization=pol,
+            dimension=d5,
+        )
+        H3 = magpy.getH(
+            "CylinderSegment",
+            poso,
+            polarization=pol,
+            dimension=d5,
+        )
+
+        np.testing.assert_allclose(B1, B2)
+        np.testing.assert_allclose(B1, B3)
+        np.testing.assert_allclose(B1, B0)
+
+        np.testing.assert_allclose(H1, H2)
+        np.testing.assert_allclose(H1, H3)
+        np.testing.assert_allclose(H1, H0)
+
+
+def test_getM():
+    """getM test"""
+    m0 = (0, 0, 0)
+    m1 = (10, 200, 3000)
+    cyl = magpy.magnet.Cylinder(dimension=(2, 2), magnetization=m1)
+    obs = [
+        (2, 2, 2),
+        (0, 0, 0),
+        (0.5, 0.5, 0.5),
+        (3, 0, 0),
+    ]
+    sens = magpy.Sensor(pixel=obs)
+
+    M1 = cyl.getM(obs)
+    M2 = magpy.getM(cyl, sens)
+    M3 = sens.getM(cyl)
+
+    Mtest = np.array([m0, m1, m1, m0])
+
+    np.testing.assert_allclose(M1, Mtest)
+    np.testing.assert_allclose(M2, Mtest)
+    np.testing.assert_allclose(M3, Mtest)
+
+
+def test_getJ():
+    """getM test"""
+    j0 = (0, 0, 0)
+    j1 = (0.1, 0.2, 0.3)
+    cyl = magpy.magnet.Cylinder(
+        dimension=(2, 2),
+        polarization=j1,
+    )
+    obs = [
+        (-2, 2, -2),
+        (0, 0, 0),
+        (-0.5, -0.5, 0.5),
+        (-3, 0, 0),
+    ]
+    sens = magpy.Sensor(pixel=obs)
+
+    J1 = cyl.getJ(obs)
+    J2 = magpy.getJ(cyl, sens)
+    J3 = sens.getJ(cyl)
+    Jtest = np.array([j0, j1, j1, j0])
+
+    np.testing.assert_allclose(J1, Jtest)
+    np.testing.assert_allclose(J2, Jtest)
+    np.testing.assert_allclose(J3, Jtest)
diff --git a/tests/test_obj_CylinderSegment.py b/tests/test_obj_CylinderSegment.py
new file mode 100644
index 000000000..48e77c69f
--- /dev/null
+++ b/tests/test_obj_CylinderSegment.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_repr():
+    """test __repr__"""
+    pm2 = magpy.magnet.CylinderSegment(
+        polarization=(1, 2, 3), dimension=(1, 2, 3, 0, 90)
+    )
+    assert repr(pm2)[:15] == "CylinderSegment", "CylinderSegment repr failed"
+
+
+def test_barycenter():
+    """test if barycenter is computed correctly"""
+    cs = magpy.magnet.CylinderSegment(
+        polarization=(100, 0, 0), dimension=(1, 2, 1, 85, 170)
+    )
+
+    expected_barycenter_squeezed = np.array([-0.86248133, 1.12400755, 0.0])
+    np.testing.assert_allclose(cs.barycenter, expected_barycenter_squeezed)
+
+    cs.rotate_from_angax([76 * i for i in range(5)], "x", anchor=(0, 0, 5), start=0)
+
+    expected_barycenter_path = np.array(
+        [
+            [-0.86248133, 1.12400755, 0.0],
+            [-0.86248133, 5.12340067, 4.88101025],
+            [-0.86248133, 1.35491805, 9.94242755],
+            [-0.86248133, -4.46783198, 7.51035264],
+            [-0.86248133, -3.51665082, 1.27219099],
+        ]
+    )
+    np.testing.assert_allclose(cs.barycenter, expected_barycenter_path)
diff --git a/tests/test_obj_Dipole.py b/tests/test_obj_Dipole.py
new file mode 100644
index 000000000..e239e3b51
--- /dev/null
+++ b/tests/test_obj_Dipole.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_Dipole_basicB():
+    """Basic dipole class test"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3), position=(1, 2, 3))
+    sens = magpy.Sensor()
+
+    B = src.getB(sens)
+    Btest = np.array([3.81801774e-09, 7.63603548e-09, 1.14540532e-08])
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_Dipole_basicH():
+    """Basic dipole class test"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3), position=(1, 2, 3))
+    sens = magpy.Sensor()
+    H = src.getH(sens)
+    Htest = np.array([0.00303828, 0.00607656, 0.00911485])
+    np.testing.assert_allclose(H, Htest, rtol=1e-05, atol=1e-08)
+
+
+def test_Dipole_zero_position():
+    """Basic dipole class test"""
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+    sens = magpy.Sensor()
+    np.seterr(all="ignore")
+    B = magpy.getB(src, sens)
+    np.seterr(all="print")
+    assert all(np.isnan(B))
+
+
+def test_repr():
+    """test __repr__"""
+    dip = magpy.misc.Dipole(moment=(1, 2, 3))
+    assert repr(dip)[:6] == "Dipole", "Dipole repr failed"
diff --git a/tests/test_obj_Polyline.py b/tests/test_obj_Polyline.py
new file mode 100644
index 000000000..efffa0441
--- /dev/null
+++ b/tests/test_obj_Polyline.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibDeprecationWarning
+
+
+def test_Polyline_basic1():
+    """Basic Polyline class test"""
+    src = magpy.current.Polyline(current=100, vertices=[(1, 1, -1), (1, 1, 1)])
+    sens = magpy.Sensor()
+    B = src.getB(sens)
+
+    x = 5.77350269 * 1e-6
+    Btest = np.array([x, -x, 0])
+
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_Polyline_basic2():
+    """Basic Polyline class test 2"""
+    src = magpy.current.Polyline(current=-100, vertices=[(1, 1, -1), (1, 1, 1)])
+    sens = magpy.Sensor()
+    H = src.getH(sens)
+
+    x = 5.77350269 / 4 / np.pi * 10
+    Htest = np.array([-x, x, 0])
+
+    np.testing.assert_allclose(H, Htest)
+
+
+def test_Polyline_basic3():
+    """Basic Polyline class test 3"""
+    line1 = magpy.current.Polyline(current=100, vertices=[(1, 1, -1), (1, 1, 1)])
+    line2 = magpy.current.Polyline(
+        current=100, vertices=[(1, 1, -1), (1, 1, 1), (1, 1, -1), (1, 1, 1)]
+    )
+    sens = magpy.Sensor()
+    B = magpy.getB([line1, line2], sens)
+
+    x = 5.77350269 * 1e-6
+    Btest = np.array([(x, -x, 0)] * 2)
+
+    np.testing.assert_allclose(B, Btest)
+
+
+def test_Polyline_repr():
+    """Polyline repr test"""
+    line = magpy.current.Polyline(current=100, vertices=[(1, 1, -1), (1, 1, 1)])
+    assert repr(line)[:8] == "Polyline", "Polyline repr failed"
+
+
+def test_Polyline_specials():
+    """Polyline specials tests"""
+    line = magpy.current.Polyline(current=100, vertices=[(0, 0, 0), (1, 1, 1)])
+    b = line.getB([0, 0, 0])
+    np.testing.assert_allclose(b, np.zeros(3))
+
+    line = magpy.current.Polyline(current=100, vertices=[(0, 0, 0), (0, 0, 0)])
+    b = line.getB([1, 2, 3])
+    np.testing.assert_allclose(b, np.zeros(3))
+
+    line = magpy.current.Polyline(current=0, vertices=[(1, 2, 3), (3, 2, 1)])
+    b = line.getB([0, 0, 0])
+    np.testing.assert_allclose(b, np.zeros(3))
+
+
+def test_line_position_bug():
+    """line positions were not properly computed in collections"""
+    verts1 = np.array([(1, 0, 0), (0, 1, 0), (-1, 0, 0)])
+    verts2 = np.array([(1, 0, 0), (0, 1, 0), (-1, 0, 0), (0, -1, 0)])
+
+    poso = [[(0, 0.99, 0.5), (0, 0.99, -0.5)]] * 3
+
+    s1 = magpy.current.Polyline(1, verts1 + np.array((0, 0, 0.5)))
+    s2 = magpy.current.Polyline(1, verts2 - np.array((0, 0, 0.5)))
+    col = s1 + s2
+    # B1 = col.getB([(0,.99,.5), (0,.99,-.5)])
+    B1 = col.getB(poso)
+
+    s1 = magpy.current.Polyline(1, verts1, position=(0, 0, 0.5))
+    s2 = magpy.current.Polyline(1, verts2, position=(0, 0, -0.5))
+    col = s1 + s2
+    # B2 = col.getB([(0,.99,.5), (0,.99,-.5)])
+    B2 = col.getB(poso)
+
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_discontinous_line():
+    """test discontinuous line"""
+
+    line_1 = magpy.current.Polyline(
+        current=1,
+        vertices=[
+            [0, 0, 0],
+            [0, 0, 1],
+        ],
+    )
+    line_2 = magpy.current.Polyline(
+        current=1,
+        vertices=[
+            [1, 0, 0],
+            [1, 0, 1],
+        ],
+    )
+    line_12 = magpy.current.Polyline(
+        current=1,
+        vertices=[
+            [None, None, None],
+            *line_1.vertices.tolist(),
+            [None, None, None],
+            [None, None, None],
+            *line_2.vertices.tolist(),
+            [None, None, None],
+        ],
+    )
+
+    B1 = magpy.getB((line_1, line_2), (0, 0, 0), sumup=True)
+    B2 = line_12.getB((0, 0, 0))
+
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_old_Line_deprecation_warning():
+    """test old class deprecation warning"""
+    with pytest.warns(MagpylibDeprecationWarning):
+        old_class = magpy.current.Line()
+
+    new_class = magpy.current.Polyline()
+    assert isinstance(old_class, magpy.current.Polyline)
+    assert isinstance(new_class, magpy.current.Polyline)
diff --git a/tests/test_obj_Sensor.py b/tests/test_obj_Sensor.py
new file mode 100644
index 000000000..4aa4f2cf1
--- /dev/null
+++ b/tests/test_obj_Sensor.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_sensor1():
+    """self-consistent test of the sensor class"""
+    pm = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    angs = np.linspace(0, 555, 44)
+    possis = [
+        (3 * np.cos(t / 180 * np.pi), 3 * np.sin(t / 180 * np.pi), 1) for t in angs
+    ]
+    sens = magpy.Sensor()
+    sens.move((3, 0, 1))
+    sens.rotate_from_angax(angs, "z", start=0, anchor=0)
+    sens.rotate_from_angax(-angs, "z", start=0)
+
+    B1 = pm.getB(possis)
+    B2 = sens.getB(pm)
+
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_sensor2():
+    """self-consistent test of the sensor class"""
+    pm = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    poz = np.linspace(0, 5, 33)
+    poss1 = [(t, 0, 2) for t in poz]
+    poss2 = [(t, 0, 3) for t in poz]
+    poss3 = [(t, 0, 4) for t in poz]
+    B1 = np.array([pm.getB(poss) for poss in [poss1, poss2, poss3]])
+    B1 = np.swapaxes(B1, 0, 1)
+
+    sens = magpy.Sensor(pixel=[(0, 0, 2), (0, 0, 3), (0, 0, 4)])
+    sens.move([(t, 0, 0) for t in poz], start=0)
+    B2 = sens.getB(pm)
+
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_Sensor_getB_specs():
+    """test input of sens getB"""
+    sens1 = magpy.Sensor(pixel=(4, 4, 4))
+    pm1 = magpy.magnet.Cylinder(polarization=(111, 222, 333), dimension=(1, 2))
+
+    B1 = sens1.getB(pm1)
+    B2 = magpy.getB(pm1, sens1)
+    np.testing.assert_allclose(B1, B2)
+
+
+def test_Sensor_squeeze():
+    """testing squeeze output"""
+    src = magpy.magnet.Sphere(polarization=(1, 1, 1), diameter=1)
+    sensor = magpy.Sensor(pixel=[(1, 2, 3), (1, 2, 3)])
+    B = sensor.getB(src)
+    assert B.shape == (2, 3)
+    H = sensor.getH(src)
+    assert H.shape == (2, 3)
+
+    B = sensor.getB(src, squeeze=False)
+    assert B.shape == (1, 1, 1, 2, 3)
+    H = sensor.getH(src, squeeze=False)
+    assert H.shape == (1, 1, 1, 2, 3)
+
+
+def test_repr():
+    """test __repr__"""
+    sens = magpy.Sensor()
+    assert repr(sens)[:6] == "Sensor", "Sensor repr failed"
+
+
+def test_pixel1():
+    """
+    squeeze=False Bfield minimal shape is (1,1,1,1,3)
+    logic: single sensor, scalar path, single source all generate
+    1 for squeeze=False Bshape. Bare pixel should do the same
+    """
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+
+    # squeeze=False Bshape of nbare pixel must be (1,1,1,1,3)
+    np.testing.assert_allclose(
+        src.getB(magpy.Sensor(pixel=(1, 2, 3)), squeeze=False).shape,
+        (1, 1, 1, 1, 3),
+    )
+
+    # squeeze=False Bshape of [(1,2,3)] must then also be (1,1,1,1,3)
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+    np.testing.assert_allclose(
+        src.getB(magpy.Sensor(pixel=[(1, 2, 3)]), squeeze=False).shape,
+        (1, 1, 1, 1, 3),
+    )
+
+    # squeeze=False Bshape of [[(1,2,3)]] must be (1,1,1,1,1,3)
+    np.testing.assert_allclose(
+        src.getB(magpy.Sensor(pixel=[[(1, 2, 3)]]), squeeze=False).shape,
+        (1, 1, 1, 1, 1, 3),
+    )
+
+
+def test_pixel2():
+    """
+    Sensor(pixel=pos_vec).pixel should always return pos_vec
+    """
+
+    p0 = (1, 2, 3)
+    p1 = [(1, 2, 3)]
+    p2 = [[(1, 2, 3)]]
+
+    # input pos_vec == Sensor(pixel=pos_vec)
+    for pos_vec in [p0, p1, p2]:
+        np.testing.assert_allclose(
+            magpy.Sensor(pixel=pos_vec).pixel,
+            pos_vec,
+        )
+
+
+def test_pixel3():
+    """
+    There should be complete equivalence between pos_vec and
+    Sensor(pixel=pos_vec) inputs
+    """
+    src = magpy.misc.Dipole(moment=(1, 2, 3))
+
+    p0 = (1, 2, 3)
+    p1 = [(1, 2, 3)]
+    p2 = [[(1, 2, 3)]]
+    for pos_vec in [p0, p1, p2]:
+        np.testing.assert_allclose(
+            src.getB(magpy.Sensor(pixel=pos_vec), squeeze=False),
+            src.getB(pos_vec, squeeze=False),
+        )
diff --git a/tests/test_obj_Sphere.py b/tests/test_obj_Sphere.py
new file mode 100644
index 000000000..bbf0bad4a
--- /dev/null
+++ b/tests/test_obj_Sphere.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+import numpy as np
+
+import magpylib as magpy
+from magpylib._src.fields.field_BH_sphere import BHJM_magnet_sphere
+
+# # """data generation for test_Sphere()"""
+
+# # N = 100
+
+# # mags = (np.random.rand(N,3)-0.5)*1000
+# # dims = np.random.rand(N)*5
+# # posos = (np.random.rand(N,333,3)-0.5)*10 #readout at 333 positions
+
+# # angs =  (np.random.rand(N,18)-0.5)*2*10 # each step rote by max 10 deg
+# # axs =   (np.random.rand(N,18,3)-0.5)
+# # anchs = (np.random.rand(N,18,3)-0.5)*5.5
+# # movs =  (np.random.rand(N,18,3)-0.5)*0.5
+
+# # B = []
+# # for mag,dim,ang,ax,anch,mov,poso in zip(mags,dims,angs,axs,anchs,movs,posos):
+# #     pm = magpy.magnet.Sphere(polarization=mag, dimension=dim)
+
+# #     # 18 subsequent operations
+# #     for a,aa,aaa,mv in zip(ang,ax,anch,mov):
+# #         pm.move(mv).rotate_from_angax(a,aa,aaa)
+
+# #     B += [pm.getB(poso)]
+# # B = np.array(B)
+
+# # inp = [mags,dims,posos,angs,axs,anchs,movs,B]
+
+# # pickle.dump(inp, open('testdata_Sphere.p', 'wb'))
+
+
+def test_Sphere_basics():
+    """test Cuboid fundamentals, test against magpylib2 fields"""
+    # data generated below
+    with Path("tests/testdata/testdata_Sphere.p").resolve().open("rb") as f:
+        data = pickle.load(f)
+    mags, dims, posos, angs, axs, anchs, movs, B = data
+
+    btest = []
+    for mag, dim, ang, ax, anch, mov, poso in zip(
+        mags, dims, angs, axs, anchs, movs, posos, strict=False
+    ):
+        pm = magpy.magnet.Sphere(polarization=mag, diameter=dim)
+
+        # 18 subsequent operations
+        for a, aa, aaa, mv in zip(ang, ax, anch, mov, strict=False):
+            pm.move(mv).rotate_from_angax(a, aa, aaa, start=-1)
+
+        btest += [pm.getB(poso)]
+    btest = np.array(btest)
+
+    np.testing.assert_allclose(B, btest)
+
+
+def test_Sphere_add():
+    """testing __add__"""
+    src1 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=11)
+    src2 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=11)
+    col = src1 + src2
+    assert isinstance(col, magpy.Collection), "adding cuboids fail"
+
+
+def test_Sphere_squeeze():
+    """testing squeeze output"""
+    src1 = magpy.magnet.Sphere(polarization=(1, 1, 1), diameter=1)
+    sensor = magpy.Sensor(pixel=[(1, 2, 3), (1, 2, 3)])
+    B = src1.getB(sensor)
+    assert B.shape == (2, 3)
+    H = src1.getH(sensor)
+    assert H.shape == (2, 3)
+
+    B = src1.getB(sensor, squeeze=False)
+    assert B.shape == (1, 1, 1, 2, 3)
+    H = src1.getH(sensor, squeeze=False)
+    assert H.shape == (1, 1, 1, 2, 3)
+
+
+def test_repr():
+    """test __repr__"""
+    pm3 = magpy.magnet.Sphere(polarization=(1, 2, 3), diameter=3)
+    assert repr(pm3)[:6] == "Sphere", "Sphere repr failed"
+
+
+def test_sphere_object_vs_lib():
+    """
+    tests object vs lib computation
+    this also checks if np.int (from array slice) is allowed as input
+    """
+    pol = np.array([(10, 20, 30)])
+    dia = np.array([1])
+    pos = np.array([(2, 2, 2)])
+    B1 = BHJM_magnet_sphere(field="B", observers=pos, polarization=pol, diameter=dia)[0]
+
+    src = magpy.magnet.Sphere(polarization=pol[0], diameter=dia[0])
+    B2 = src.getB(pos)
+
+    np.testing.assert_allclose(B1, B2)
diff --git a/tests/test_obj_Tetrahedron.py b/tests/test_obj_Tetrahedron.py
new file mode 100644
index 000000000..56da15a83
--- /dev/null
+++ b/tests/test_obj_Tetrahedron.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+
+
+def test_Tetrahedron_repr():
+    """Tetrahedron repr test"""
+    tetra = magpy.magnet.Tetrahedron()
+    assert repr(tetra)[:11] == "Tetrahedron", "Tetrahedron repr failed"
+
+
+def test_tetra_input():
+    """test obj-oriented triangle vs cube"""
+    obs = (1, 2, 3)
+    pol = (111, 222, 333)
+    vert_list = [
+        [(1, 1, -1), (1, 1, 1), (-1, 1, 1), (1, -1, 1)],
+        [(-1, -1, 1), (-1, 1, 1), (1, -1, 1), (1, -1, -1)],
+        [(-1, -1, -1), (-1, -1, 1), (-1, 1, -1), (1, -1, -1)],
+        [(-1, 1, -1), (1, -1, -1), (-1, -1, 1), (-1, 1, 1)],
+        [(1, -1, -1), (1, 1, -1), (1, -1, 1), (-1, 1, 1)],
+        [(-1, 1, -1), (-1, 1, 1), (1, 1, -1), (1, -1, -1)],
+    ]
+
+    coll = magpy.Collection()
+    for v in vert_list:
+        coll.add(magpy.magnet.Tetrahedron(polarization=pol, vertices=v))
+
+    cube = magpy.magnet.Cuboid(polarization=pol, dimension=(2, 2, 2))
+
+    b = coll.getB(obs)
+    bb = cube.getB(obs)
+    np.testing.assert_allclose(b, bb)
+
+    h = coll.getH(obs)
+    hh = cube.getH(obs)
+    np.testing.assert_allclose(h, hh)
+
+
+@pytest.mark.parametrize(
+    "vertices",
+    [
+        1,
+        [[(1, 1, -1), (1, 1, 1), (-1, 1, 1), (1, -1, 1)]] * 2,
+        [(1, 1, -1), (1, 1, 1), (-1, 1, 1)],
+        "123",
+    ],
+)
+def test_tetra_bad_inputs(vertices):
+    """test obj-oriented triangle vs cube"""
+
+    with pytest.raises(MagpylibBadUserInput):
+        magpy.magnet.Tetrahedron(polarization=(0.111, 0.222, 0.333), vertices=vertices)
+
+
+def test_tetra_barycenter():
+    """get barycenter"""
+    pol = (0.111, 0.222, 0.333)
+    vert = [(1, 1, -1), (1, 1, 1), (-1, 1, 1), (1, -1, 1)]
+    tetra = magpy.magnet.Tetrahedron(polarization=pol, vertices=vert)
+    np.testing.assert_allclose(tetra.barycenter, (0.5, 0.5, 0.5))
+
+
+def test_tetra_in_out():
+    """test inside and outside"""
+    pol = (0.111, 0.222, 0.333)
+    vert = [(-1, -1, -1), (0, 1, -1), (1, 0, -1), (0, 0, 1)]
+    tetra = magpy.magnet.Tetrahedron(polarization=pol, vertices=vert)
+
+    obs_in = [(0, 0, 0), (0.1, 0.1, 0.1), (0.2, 0.2, 0.2)]
+    obs_out = [(2, 0, 0), (2, 2, 2), (0.2, 2, 0.2)]
+
+    Bauto = magpy.getB(tetra, obs_in)
+    Bin = magpy.getB(tetra, obs_in, in_out="inside")
+    np.testing.assert_allclose(Bauto, Bin)
+
+    Bauto = magpy.getB(tetra, obs_out)
+    Bout = magpy.getB(tetra, obs_out, in_out="outside")
+    np.testing.assert_allclose(Bauto, Bout)
diff --git a/tests/test_obj_Triangle.py b/tests/test_obj_Triangle.py
new file mode 100644
index 000000000..d71832210
--- /dev/null
+++ b/tests/test_obj_Triangle.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibMissingInput
+from magpylib._src.fields.field_BH_triangle import BHJM_triangle
+
+
+def test_Triangle_repr():
+    """Triangle repr test"""
+    line = magpy.misc.Triangle()
+    assert repr(line)[:8] == "Triangle", "Triangle repr failed"
+
+
+def test_triangle_input1():
+    """test obj-oriented triangle vs cube"""
+    obs = (1, 2, 3)
+    pol = (0, 0, 333)
+    vert = np.array(
+        [
+            [(-1, -1, 1), (1, -1, 1), (-1, 1, 1)],  # top1
+            [(1, -1, -1), (-1, -1, -1), (-1, 1, -1)],  # bott1
+            [(1, -1, 1), (1, 1, 1), (-1, 1, 1)],  # top2
+            [(1, 1, -1), (1, -1, -1), (-1, 1, -1)],  # bott2
+        ]
+    )
+    coll = magpy.Collection()
+    for v in vert:
+        coll.add(magpy.misc.Triangle(polarization=pol, vertices=v))
+    cube = magpy.magnet.Cuboid(polarization=pol, dimension=(2, 2, 2))
+
+    b = coll.getB(obs)
+    bb = cube.getB(obs)
+
+    np.testing.assert_allclose(b, bb)
+
+
+def test_triangle_input3():
+    """test core triangle vs objOriented triangle"""
+
+    obs = np.array([(3, 4, 5)] * 4)
+    pol = np.array([(111, 222, 333)] * 4)
+    vert = np.array(
+        [
+            [(0, 0, 0), (3, 0, 0), (0, 10, 0)],
+            [(3, 0, 0), (5, 0, 0), (0, 10, 0)],
+            [(5, 0, 0), (6, 0, 0), (0, 10, 0)],
+            [(6, 0, 0), (10, 0, 0), (0, 10, 0)],
+        ]
+    )
+    b = BHJM_triangle(field="B", observers=obs, polarization=pol, vertices=vert)
+    b = np.sum(b, axis=0)
+
+    tri1 = magpy.misc.Triangle(polarization=pol[0], vertices=vert[0])
+    tri2 = magpy.misc.Triangle(polarization=pol[0], vertices=vert[1])
+    tri3 = magpy.misc.Triangle(polarization=pol[0], vertices=vert[2])
+    tri4 = magpy.misc.Triangle(polarization=pol[0], vertices=vert[3])
+
+    bb = magpy.getB([tri1, tri2, tri3, tri4], obs[0], sumup=True)
+
+    np.testing.assert_allclose(b, bb)
+
+
+def test_empty_object_initialization():
+    """empty object init and error msg"""
+
+    fac = magpy.misc.Triangle()
+
+    def call_getB():
+        """dummy function call getB"""
+        fac.getB()
+
+    np.testing.assert_raises(MagpylibMissingInput, call_getB)
+
+
+def test_Triangle_barycenter():
+    """test Triangle barycenter"""
+    pol = (0, 0, 0.333)
+    vert = ((-1, -1, 0), (1, -1, 0), (0, 2, 0))
+    face = magpy.misc.Triangle(polarization=pol, vertices=vert)
+    bary = np.array([0, 0, 0])
+    np.testing.assert_allclose(face.barycenter, bary)
diff --git a/tests/test_obj_TriangularMesh.py b/tests/test_obj_TriangularMesh.py
new file mode 100644
index 000000000..e4a70ec55
--- /dev/null
+++ b/tests/test_obj_TriangularMesh.py
@@ -0,0 +1,489 @@
+from __future__ import annotations
+
+import re
+import sys
+import warnings
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+import pyvista as pv
+
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibBadUserInput
+from magpylib._src.fields.field_BH_triangularmesh import (
+    BHJM_magnet_trimesh,
+    fix_trimesh_orientation,
+    lines_end_in_trimesh,
+)
+
+
+def test_TriangularMesh_repr():
+    """TriangularMesh repr test"""
+    trimesh = magpy.magnet.TriangularMesh.from_pyvista(
+        polarization=(0, 0, 1), polydata=pv.Octahedron()
+    )
+    assert repr(trimesh).startswith("TriangularMesh"), "TriangularMesh repr failed"
+
+
+def test_TriangularMesh_barycenter():
+    """test TriangularMesh barycenter"""
+    pol = (0, 0, 333)
+    trimesh = magpy.magnet.TriangularMesh.from_pyvista(
+        polarization=pol, polydata=pv.Octahedron()
+    ).move((1, 2, 3))
+    bary = np.array([1, 2, 3])
+    np.testing.assert_allclose(trimesh.barycenter, bary)
+
+
+def test_TriangularMesh_getBH():
+    """Compare meshed cube to magpylib cube"""
+    dimension = (1, 1, 1)
+    polarization = (100, 200, 300)
+    mesh3d = magpy.graphics.model3d.make_Cuboid()
+    vertices = np.array([v for k, v in mesh3d["kwargs"].items() if k in "xyz"]).T
+    faces = np.array([v for k, v in mesh3d["kwargs"].items() if k in "ijk"]).T
+
+    faces[0] = faces[0][[0, 2, 1]]  # flip one triangle in wrong orientation
+
+    cube = magpy.magnet.Cuboid(polarization=polarization, dimension=dimension)
+    cube.rotate_from_angax(19, (1, 2, 3))
+    cube.move((1, 2, 3))
+
+    cube_facet_reorient_true = magpy.magnet.TriangularMesh(
+        position=cube.position,
+        orientation=cube.orientation,
+        polarization=polarization,
+        vertices=vertices,
+        faces=faces,
+        reorient_faces=True,
+    )
+    cube_misc_faces = cube_facet_reorient_true.to_TriangleCollection()
+    cube_facet_reorient_false = magpy.magnet.TriangularMesh(
+        position=cube.position,
+        orientation=cube.orientation,
+        polarization=polarization,
+        vertices=vertices,
+        faces=faces,
+        reorient_faces=False,
+    )
+
+    vertices = np.linspace((-2, 0, 0), (2, 0, 0), 50)
+    B1 = cube.getB(vertices)
+    B2 = cube_facet_reorient_true.getB(vertices)
+    B3 = cube_misc_faces.getB(vertices)
+    B4 = cube_facet_reorient_false.getB(vertices)
+
+    np.testing.assert_allclose(B1, B2)
+    np.testing.assert_allclose(B1, B3)
+
+    with pytest.raises(AssertionError):
+        np.testing.assert_allclose(B1, B4)
+
+    H1 = cube.getH(vertices)
+    H2 = cube_facet_reorient_true.getH(vertices)
+    H3 = cube_misc_faces.getH(vertices)
+
+    np.testing.assert_allclose(H1, H2)
+    np.testing.assert_allclose(H1, H3)
+
+
+def test_TriangularMesh_getB_different_facet_shapes_mixed():
+    """test different facet shapes, facet cube has
+    shape (12,3,3) vs (4,3,3) for facet tetrahedron"""
+    tetra_pv = pv.Tetrahedron()
+    tetra = (
+        magpy.magnet.Tetrahedron(
+            polarization=(0.444, 0.555, 0.666), vertices=tetra_pv.points
+        )
+        .move((-1, 1, 1))
+        .rotate_from_angax([14, 65, 97], (4, 6, 9), anchor=0)
+    )
+    tetra_kwargs = {
+        "polarization": tetra.polarization,
+        "position": tetra.position,
+        "orientation": tetra.orientation,
+    }
+    tmesh_tetra = magpy.magnet.TriangularMesh.from_pyvista(
+        polydata=tetra_pv, **tetra_kwargs
+    )
+    assert tmesh_tetra.status_reoriented is True
+    cube = (
+        magpy.magnet.Cuboid(polarization=(0.111, 0.222, 0.333), dimension=(1, 1, 1))
+        .move((1, 1, 1))
+        .rotate_from_angax([14, 65, 97], (4, 6, 9), anchor=0)
+    )
+    cube_kwargs = {
+        "polarization": cube.polarization,
+        "position": cube.position,
+        "orientation": cube.orientation,
+    }
+    tmesh_cube = magpy.magnet.TriangularMesh.from_pyvista(
+        polydata=pv.Cube(), **cube_kwargs
+    )
+    # create a sensor of which the pixel line crosses both bodies
+    sens = magpy.Sensor(pixel=np.linspace((-2, 1, 1), (2, 1, 1))).rotate_from_angax(
+        [14, 65, 97], (4, 6, 9), anchor=0
+    )
+
+    np.testing.assert_allclose(magpy.getB(cube, sens), magpy.getB(tmesh_cube, sens))
+    np.testing.assert_allclose(magpy.getB(tetra, sens), magpy.getB(tmesh_tetra, sens))
+    np.testing.assert_allclose(
+        magpy.getB([tetra, cube], sens), magpy.getB([tmesh_tetra, tmesh_cube], sens)
+    )
+
+
+def test_magnet_trimesh_func():
+    """test on manual inside"""
+    pol = (0.111, 0.222, 0.333)
+    dim = (10, 10, 10)
+    cube = magpy.magnet.Cuboid(polarization=pol, dimension=dim)
+    tmesh_cube = magpy.magnet.TriangularMesh.from_pyvista(
+        polarization=pol, polydata=pv.Cube(cube.position, *dim)
+    )
+
+    pts_inside = np.array([[0, 0, 1]])
+    B0 = cube.getB(pts_inside)
+    B1 = tmesh_cube.getB(pts_inside)
+    B2 = BHJM_magnet_trimesh(
+        field="B",
+        observers=pts_inside,
+        polarization=np.array([pol]),
+        mesh=np.array([tmesh_cube.mesh]),
+        in_out="inside",
+    )[0]
+    np.testing.assert_allclose(B0, B1)
+    np.testing.assert_allclose(B0, B2)
+
+
+def test_bad_triangle_indices():
+    "raise ValueError if faces index > len(vertices)"
+    vertices = [[0, 0, 0], [0, 0, 1], [1, 0, 0]]
+    faces = [[1, 2, 3]]  # index 3 >= len(vertices)
+    with pytest.raises(IndexError):
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+        )
+
+
+def test_open_mesh():
+    """raises Error if mesh is open"""
+    open_mesh = {
+        "i": [7, 0, 0, 0, 4, 4, 2, 6, 4, 0, 3],
+        "j": [0, 7, 1, 2, 6, 7, 1, 2, 5, 5, 2],
+        "k": [3, 4, 2, 3, 5, 6, 5, 5, 0, 1, 7],
+        "x": [-1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0],
+        "y": [-1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0],
+        "z": [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0],
+    }
+    vertices = np.array([v for k, v in open_mesh.items() if k in "xyz"]).T
+    faces = np.array([v for k, v in open_mesh.items() if k in "ijk"]).T
+    with pytest.raises(ValueError, match=r"Open mesh detected in .*."):
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="raise",
+        )
+    with pytest.raises(ValueError, match=r"Open mesh in .* detected."):
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="ignore",
+            reorient_faces="raise",
+        )
+    with pytest.warns(UserWarning) as record:
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="warn",
+        )
+        assert len(record) == 2
+        assert re.match(r"Open mesh detected in .*.", str(record[0].message))
+        assert re.match(r"Open mesh in .* detected.", str(record[1].message))
+
+    with pytest.warns(UserWarning) as record:
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="skip",
+            reorient_faces="warn",
+        )
+        assert len(record) == 3
+        assert re.match(
+            r"Unchecked mesh status in .* detected. Now applying check_open()",
+            str(record[0].message),
+        )
+        assert re.match(r"Open mesh detected in .*.", str(record[1].message))
+        assert re.match(r"Open mesh in .* detected.", str(record[2].message))
+
+    with warnings.catch_warnings():  # no warning should be issued!
+        warnings.simplefilter("error")
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="ignore",
+            reorient_faces="ignore",
+        )
+
+    with pytest.warns(
+        UserWarning,
+        match=r"Open mesh of .* detected",
+    ):
+        mesh = magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="ignore",
+            reorient_faces="ignore",
+        )
+        mesh.getB((0, 0, 0))
+
+    with pytest.warns(UserWarning, match=r"Unchecked mesh status of .* detected"):
+        mesh = magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_open="skip",
+            reorient_faces="skip",
+        )
+        mesh.getB((0, 0, 0))
+
+
+def test_disconnected_mesh():
+    """raises Error if mesh is not connected"""
+    #  Multiple Text3D letters are disconnected
+    with pytest.raises(ValueError, match=r"Disconnected mesh detected in .*."):
+        magpy.magnet.TriangularMesh.from_pyvista(
+            polarization=(0, 0, 1),
+            polydata=pv.Text3D("AB"),
+            check_disconnected="raise",
+        )
+
+
+def test_selfintersecting_triangular_mesh():
+    """raises Error if self intersecting"""
+    # cube with closed with an inverted pyramid crossing the opposite face.
+    selfintersecting_mesh3d = {
+        "x": [-1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 0.0],
+        "y": [-1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 0.0],
+        "z": [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -2.0],
+        "i": [7, 0, 0, 0, 2, 6, 4, 0, 3, 7, 4, 5, 6, 7],
+        "j": [0, 7, 1, 2, 1, 2, 5, 5, 2, 2, 5, 6, 7, 4],
+        "k": [3, 4, 2, 3, 5, 5, 0, 1, 7, 6, 8, 8, 8, 8],
+    }
+    vertices = np.array([v for k, v in selfintersecting_mesh3d.items() if k in "xyz"]).T
+    faces = np.array([v for k, v in selfintersecting_mesh3d.items() if k in "ijk"]).T
+    with pytest.raises(ValueError, match=r"Self-intersecting mesh detected in .*."):
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_selfintersecting="raise",
+        )
+    with pytest.warns(UserWarning, match=r"Self-intersecting mesh detected in .*."):
+        magpy.magnet.TriangularMesh(
+            polarization=(0, 0, 1),
+            vertices=vertices,
+            faces=faces,
+            check_selfintersecting="warn",
+        )
+
+
+def test_TriangularMesh_from_pyvista():
+    """Test from_pyvista classmethod"""
+
+    def get_tri_from_pv(obj):
+        return magpy.magnet.TriangularMesh.from_pyvista(
+            polarization=(0, 0, 1), polydata=obj
+        )
+
+    # should work
+    get_tri_from_pv(pv.Cube())
+
+    # should fail
+    with pytest.raises(TypeError):
+        get_tri_from_pv("bad_pyvista_obj_input")
+
+    # Should raise if pyvista is not installed
+    with patch.dict(sys.modules, {"pyvista": None}), pytest.raises(ModuleNotFoundError):
+        get_tri_from_pv(pv.Cube())
+
+
+def test_TriangularMesh_from_faces_bad_inputs():
+    """Test from_faces classmethod bad inputs"""
+    pol = (0, 0, 1)
+    kw = {
+        "polarization": pol,
+        "check_open": False,
+        "check_disconnected": False,
+        "reorient_faces": False,
+    }
+
+    def get_tri_from_triangles(trias):
+        return magpy.magnet.TriangularMesh.from_triangles(triangles=trias, **kw)
+
+    def get_tri_from_mesh(mesh):
+        return magpy.magnet.TriangularMesh.from_mesh(mesh=mesh, **kw)
+
+    triangle = magpy.misc.Triangle(
+        polarization=pol, vertices=[(0, 0, 0), (1, 0, 0), (0, 1, 0)]
+    )
+
+    # good element type but not array-like
+    with pytest.raises(
+        TypeError,
+        match=r"The `triangles` parameter must be a list or Collection of `Triangle` objects*.",
+    ):
+        get_tri_from_triangles(triangle)
+
+    # element in list has wrong type
+    with pytest.raises(
+        TypeError, match=r"All elements of `triangles` must be `Triangle` objects*."
+    ):
+        get_tri_from_triangles(["bad_type"])
+
+    # bad type input
+    with pytest.raises(MagpylibBadUserInput):
+        get_tri_from_mesh(1)
+
+    # bad shape input
+    msh = [((0, 0), (1, 0), (0, 1))] * 2
+    with pytest.raises(ValueError, match=r"Input parameter `mesh` has bad shape*."):
+        get_tri_from_mesh(msh)
+
+    # bad shape input
+    msh = [((0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1))] * 2
+    with pytest.raises(ValueError, match=r"Input parameter `mesh` has bad shape*."):
+        get_tri_from_mesh(msh)
+
+
+def test_TriangularMesh_from_faces_good_inputs():
+    """Test from_faces classmethod good inputs"""
+    pol = (0, 0, 1)
+
+    # create Tetrahedron and move/orient randomly
+    tetra = magpy.magnet.Tetrahedron(
+        polarization=pol, vertices=[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]
+    )
+    tetra.move((3, 4, 5)).rotate_from_angax([13, 37], (1, 2, 3), anchor=0)
+    pos_ori = {"orientation": tetra.orientation, "position": tetra.position}
+
+    tmesh1 = magpy.magnet.TriangularMesh.from_ConvexHull(
+        polarization=pol, points=tetra.vertices, **pos_ori
+    )
+
+    # from triangle list
+    trias = [
+        magpy.misc.Triangle(polarization=pol, vertices=face) for face in tmesh1.mesh
+    ]
+    tmesh2 = magpy.magnet.TriangularMesh.from_triangles(
+        polarization=pol, triangles=trias, **pos_ori
+    )
+
+    # from collection
+    coll = magpy.Collection(trias)
+    tmesh3 = magpy.magnet.TriangularMesh.from_triangles(
+        polarization=pol, triangles=coll, **pos_ori
+    )
+
+    # from mesh
+    msh = [t.vertices for t in coll]
+    tmesh4 = magpy.magnet.TriangularMesh.from_mesh(
+        polarization=pol, mesh=msh, **pos_ori
+    )
+
+    points = [0, 0, 0]
+    B0 = tetra.getB(points)
+    B1 = tmesh1.getB(points)
+    B2 = tmesh2.getB(points)
+    B3 = tmesh3.getB(points)
+    B4 = tmesh4.getB(points)
+
+    np.testing.assert_allclose(B0, B1)
+    np.testing.assert_allclose(B0, B2)
+    np.testing.assert_allclose(B0, B3)
+    np.testing.assert_allclose(B0, B4)
+
+    # # 2-> test getB vs ConvexHull Tetrahedron faces as msh
+    # msh = tetra_from_ConvexHull.mesh
+    # src2 = get_tri_from_faces(msh, **pos_orient)
+    # B2 = src2.getB(points)
+    # np.testing.assert_allclose(B0, B2)
+
+    # # 3-> test getB vs ConvexHull Tetrahedron faces as magpylib.misc.Triangles
+    # msh = [magpy.misc.Triangle(mag, face) for face in tetra_from_ConvexHull.mesh]
+    # src3 = get_tri_from_faces(msh, **pos_orient)
+    # B3 = src3.getB(points)
+    # np.testing.assert_allclose(B0, B3)
+
+    # # 4-> test getB mixed input
+    # msh = [magpy.misc.Triangle(mag, face) for face in tetra_from_ConvexHull.mesh]
+    # msh[-1] = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
+    # src4 = get_tri_from_faces(msh, **pos_orient)
+    # B4 = src4.getB(points)
+    # np.testing.assert_allclose(B0, B4)
+
+
+def test_lines_ends_in_trimesh():
+    "test special cases"
+
+    # line point coincides with facet point
+    msh = np.array([[[0, 0, 0], [0, 1, 0], [1, 0, 0]]])
+    lines = np.array([[[-1, 1, -1], [1, 0, 0]]])
+
+    assert bool(lines_end_in_trimesh(lines, msh)[0]) is True
+
+
+def test_reorient_on_closed_but_disconnected_mesh():
+    """Reorient edge case"""
+    N = 3
+    s1 = pv.Sphere(theta_resolution=N, phi_resolution=N)
+    s2 = pv.Sphere(theta_resolution=N, phi_resolution=N, center=(2, 0, 0))
+    polydata = s1.merge(s2)
+    faces = polydata.faces.reshape(-1, 4)[:, 1:]
+    vertices = polydata.points
+
+    # flip every 2nd normals
+    bad_faces = faces.copy()
+    bad_faces[::2] = bad_faces[::2, [0, 2, 1]]
+
+    fixed_faces = fix_trimesh_orientation(vertices, bad_faces)
+    np.testing.assert_array_equal(faces, fixed_faces)
+
+
+def test_bad_mode_input():
+    """test bad mode input"""
+    with pytest.raises(
+        ValueError,
+        match=r"The `check_open mode` argument .*, instead received 'badinput'.",
+    ):
+        magpy.magnet.TriangularMesh.from_pyvista(
+            polarization=(0, 0, 1), polydata=pv.Octahedron(), check_open="badinput"
+        )
+
+
+def test_orientation_edge_case():
+    """test reorientation edge case"""
+
+    # reorientation may fail if the face orientation vector is two small
+    # see issue #636
+
+    def points(r0):
+        return [(r0 * np.cos(t), r0 * np.sin(t), 10) for t in ts] + [(0, 0, 0)]
+
+    ts = np.linspace(0, 2 * np.pi, 5)
+    cone1 = magpy.magnet.TriangularMesh.from_ConvexHull(
+        polarization=(0, 0, 1), points=points(12)
+    )
+    cone2 = magpy.magnet.TriangularMesh.from_ConvexHull(
+        polarization=(0, 0, 1), points=points(13)
+    )
+
+    np.testing.assert_array_equal(cone1.faces, cone2.faces)
diff --git a/tests/test_package.py b/tests/test_package.py
new file mode 100644
index 000000000..ecd393909
--- /dev/null
+++ b/tests/test_package.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import importlib.metadata
+
+import magpylib as m
+
+
+def test_version():
+    assert importlib.metadata.version("magpylib") == m.__version__
diff --git a/tests/test_path.py b/tests/test_path.py
new file mode 100644
index 000000000..633a43df2
--- /dev/null
+++ b/tests/test_path.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_path_old_new_move():
+    """test path move and compare to old style computation"""
+    n = 100
+    s_pos = (0, 0, 0)
+
+    # path style code translation
+    pm1 = magpy.magnet.Cylinder(
+        polarization=(0, 0, 1), dimension=(3, 3), position=(-5, 0, 3)
+    )
+    pm1.move([(x, 0, 0) for x in np.linspace(0, 10, 100)], start=-1)
+    B1 = pm1.getB(s_pos)
+
+    # old style code translation
+    pm2 = magpy.magnet.Cylinder(
+        polarization=(0, 0, 1), dimension=(3, 3), position=(0, 0, 3)
+    )
+    ts = np.linspace(-5, 5, n)
+    possis = np.array([(t, 0, 0) for t in ts])
+    B2 = pm2.getB(possis[::-1])
+
+    np.testing.assert_allclose(B1, B2, err_msg="path move problem")
+
+
+def test_path_old_new_rotate():
+    """test path rotate
+    compare to old style computation
+    """
+
+    n = 111
+    s_pos = (0, 0, 0)
+    ax = (1, 0, 0)
+    anch = (0, 0, 10)
+
+    # path style code rotation
+    pm1 = magpy.magnet.Cuboid(
+        polarization=(0, 0, 1), dimension=(1, 2, 3), position=(0, 0, 3)
+    )
+    pm1.rotate_from_angax(-30, ax, anch)
+    pm1.rotate_from_angax(np.linspace(0, 60, n), "x", anch, start=-1)
+    B1 = pm1.getB(s_pos)
+
+    # old style code rotation
+    pm2 = magpy.magnet.Cuboid(
+        polarization=(0, 0, 1), dimension=(1, 2, 3), position=(0, 0, 3)
+    )
+    pm2.rotate_from_angax(-30, ax, anch)
+    B2 = []
+    for _ in range(n):
+        B2 += [pm2.getB(s_pos)]
+        pm2.rotate_from_angax(60 / (n - 1), ax, anch)
+    B2 = np.array(B2)
+
+    np.testing.assert_allclose(
+        B1,
+        B2,
+        rtol=1e-05,
+        atol=1e-08,
+        err_msg="path rotate problem",
+    )
diff --git a/tests/test_physics_consistency.py b/tests/test_physics_consistency.py
new file mode 100644
index 000000000..a80010a7c
--- /dev/null
+++ b/tests/test_physics_consistency.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_dipole_approximation():
+    """test if all source fields converge towards the correct dipole field at distance"""
+    pol = np.array([0.111, 0.222, 0.333])
+    pos = (1234, -234, 345)
+
+    # cuboid with volume = 1 m^3
+    src1 = magpy.magnet.Cuboid(polarization=pol, dimension=(1, 1, 1))
+    B1 = src1.getB(pos)
+
+    # Cylinder with volume = 1 m^3
+    dia = np.sqrt(4 / np.pi)
+    src2 = magpy.magnet.Cylinder(polarization=pol, dimension=(dia, 1))
+    B2 = src2.getB(pos)
+    np.testing.assert_allclose(B1, B2, rtol=1e-05, atol=1e-08)
+
+    # Sphere with volume = 1 m^3
+    dia = (6 / np.pi) ** (1 / 3)
+    src3 = magpy.magnet.Sphere(polarization=pol, diameter=dia)
+    B3 = src3.getB(pos)
+    np.testing.assert_allclose(B1, B3, rtol=1e-05, atol=1e-08)
+
+    #  Dipole with mom=pol
+    src4 = magpy.misc.Dipole(moment=pol)
+    B4 = src4.getB(pos)
+    np.testing.assert_allclose(B1, B4, rtol=1e-05, atol=1e-08)
+
+    # Circle loop vs Dipole
+    dia = 2
+    i0 = 234
+    m0 = dia**2 * np.pi**2 / 10 * i0
+    src1 = magpy.current.Circle(current=i0, diameter=dia)
+    src2 = magpy.misc.Dipole(moment=(0, 0, m0))
+    H1 = src1.getH(pos)
+    H2 = src2.getH(pos)
+    np.testing.assert_allclose(H1, H2, rtol=1e-05, atol=1e-08)
+
+
+def test_Circle_vs_Cylinder_field():
+    """
+    The H-field of a loop with radius r0 (m) and current i0 (A) is the same
+    as the H-field of a cylinder with radius r0 (m), height h0 (m) and
+    magnetization (0, 0, 4pi/10*i0/h0) !!!
+    """
+
+    # this set of position generates a strange error in celv
+    # that is now fixed. (some k2<0.04, some larger)
+    pos_obs = np.array(
+        [
+            [2.62974227e-01, 8.47810369e-01, 1.08754479e01],
+            [3.84185491e-01, 2.95441595e-01, 1.00902470e01],
+            [6.42119527e-01, 2.06572582e-01, 1.00111349e01],
+            [2.77437481e-01, 7.70421483e-01, 1.01708024e01],
+            [6.38134375e-01, 6.60523111e-01, 1.02255165e01],
+            [4.05896210e-01, 7.18840810e-01, 1.09878512e01],
+            [4.25483323e-01, 5.97011185e-01, 1.03105633e01],
+            [1.86350557e-01, 9.45180347e-01, 1.00771425e01],
+            [7.31784172e-03, 3.43762111e-01, 1.01388689e01],
+            [9.76180294e-01, 2.86980987e-01, 1.07162604e01],
+            [4.19970335e-01, 6.78011898e-01, 1.07462700e01],
+            [2.51709117e-01, 1.80214678e-01, 1.05292310e01],
+            [1.38341488e-01, 7.64969048e-01, 1.04836868e01],
+            [7.16320259e-01, 5.17108288e-01, 1.04404834e01],
+            [1.36719186e-01, 8.03444934e-02, 1.05825844e01],
+            [3.02549448e-01, 8.01158793e-01, 1.06895803e01],
+            [6.96369978e-01, 8.41086725e-01, 1.05991355e01],
+            [1.56389836e-02, 8.83332094e-01, 1.00294123e01],
+            [5.72854015e-01, 9.78889329e-01, 1.00856741e01],
+            [5.90518725e-01, 2.71810008e-01, 1.09421650e01],
+            [9.78841160e-01, 8.49649719e-01, 1.02277205e01],
+            [3.34356881e-01, 4.85928671e-01, 1.08996289e01],
+            [4.57102605e-01, 7.29004951e-01, 1.06881211e01],
+            [7.70055121e-01, 7.79513350e-01, 1.00064163e01],
+            [4.38978477e-01, 2.42722989e-01, 1.07810591e01],
+            [2.94965451e-01, 8.16939582e-01, 1.08524609e01],
+            [9.10294019e-01, 1.01999675e-01, 1.05777031e01],
+            [1.98324922e-01, 8.69170938e-01, 1.06498450e01],
+            [2.04949091e-01, 7.29157637e-02, 1.08216263e01],
+            [4.03860840e-01, 2.51733457e-01, 1.09413861e01],
+            [8.42429689e-01, 7.53521494e-01, 1.06840432e01],
+            [5.47487506e-01, 2.17112793e-01, 1.08309858e01],
+            [1.32920817e-02, 8.90027375e-01, 1.05206045e01],
+            [2.12434323e-01, 1.07809620e-01, 1.05248679e01],
+            [9.24972525e-01, 4.02334232e-01, 1.01218881e01],
+            [4.72828420e-01, 8.84518608e-01, 1.03564702e01],
+            [7.47506193e-01, 8.50172276e-02, 1.08471793e01],
+            [5.59375134e-01, 7.49345280e-01, 1.04832901e01],
+            [1.53289823e-01, 1.22688627e-01, 1.01417979e01],
+            [6.20682956e-01, 2.04842717e-01, 1.02372747e01],
+            [5.26696817e-01, 9.97967209e-01, 1.01548900e01],
+            [3.12286750e-01, 8.55676144e-02, 1.08151431e01],
+            [3.36130598e-01, 9.23647162e-01, 1.01808101e01],
+            [6.48032234e-01, 6.77714891e-01, 1.06903143e01],
+            [4.97615700e-02, 6.86552664e-01, 1.04692230e01],
+            [1.39612563e-01, 5.94597678e-01, 1.09177616e01],
+            [9.49958586e-01, 3.03275707e-01, 1.04126750e01],
+            [7.90362802e-02, 2.05629309e-01, 1.08460663e01],
+            [6.39251869e-01, 1.27717311e-01, 1.03640598e01],
+            [1.75346719e-01, 7.76144247e-01, 1.07590716e01],
+            [4.80488839e-01, 6.44113412e-01, 1.00806087e01],
+            [3.30249230e-01, 2.90567396e-01, 1.02823508e01],
+            [2.32507704e-01, 3.11357670e-01, 1.00585207e01],
+            [9.93932043e-01, 8.13588626e-01, 1.02441850e01],
+            [6.14110393e-02, 8.24710989e-01, 1.03036766e01],
+            [7.54284742e-01, 4.75888115e-01, 1.02980990e01],
+            [9.03436653e-01, 1.38604212e-02, 1.02052852e01],
+            [3.25406232e-01, 5.01599309e-01, 1.02273729e01],
+            [6.10904352e-01, 2.01374297e-02, 1.05994945e01],
+            [5.13886308e-01, 7.47646233e-01, 1.00881973e01],
+            [7.66062767e-01, 8.55628912e-01, 1.02443255e01],
+            [4.12965850e-01, 6.71639134e-02, 1.08920383e01],
+            [2.22162237e-01, 4.35458370e-01, 1.00005670e01],
+            [6.92063517e-01, 4.77425107e-01, 1.09109479e01],
+            [4.73624739e-02, 5.67853047e-01, 1.02619257e01],
+            [7.35614319e-01, 3.04928294e-01, 1.04878104e01],
+            [9.52815588e-01, 1.83929502e-01, 1.09015172e01],
+            [6.85134024e-01, 2.56932032e-01, 1.06599932e01],
+            [7.49282874e-01, 6.99614619e-01, 1.04573794e01],
+            [8.06968804e-01, 8.99615103e-01, 1.06770292e01],
+            [8.10594977e-01, 9.37427828e-01, 1.04077535e01],
+            [3.52771587e-01, 4.62098593e-01, 1.02567372e01],
+            [5.65591895e-01, 2.76154469e-01, 1.05387184e01],
+            [2.46784605e-01, 5.66301118e-01, 1.00484832e01],
+            [4.54504276e-01, 3.50320293e-01, 1.09152037e01],
+            [1.34071712e-01, 3.18619591e-01, 1.09602583e01],
+            [7.06928981e-01, 1.30956872e-01, 1.01472973e01],
+            [9.24252364e-02, 3.09994386e-01, 1.04355552e01],
+            [4.70425188e-01, 7.59610285e-01, 1.03136298e01],
+            [7.04080112e-01, 4.16336685e-01, 1.03876008e01],
+            [8.44199888e-01, 4.84203700e-01, 1.06195388e01],
+            [6.75770202e-02, 6.31321589e-01, 1.06495791e01],
+            [9.48223738e-01, 8.77564164e-01, 1.09798880e01],
+            [1.21580775e-01, 8.75070086e-01, 1.08523342e01],
+            [4.15901288e-01, 7.58294704e-01, 1.09128418e01],
+            [3.42156748e-01, 5.05791032e-01, 1.07678098e01],
+            [4.64032116e-01, 7.28064777e-01, 1.09940092e01],
+            [7.72357996e-01, 7.78746013e-01, 1.07879828e01],
+            [2.02837991e-01, 5.09599429e-01, 1.04062704e01],
+            [1.88330581e-01, 3.84815757e-02, 1.01780565e01],
+            [2.57727724e-01, 1.27152743e-01, 1.09787027e01],
+            [2.11735849e-01, 3.98791360e-02, 1.00743078e01],
+            [6.59218472e-01, 3.79855821e-01, 1.09638287e01],
+            [3.64295183e-01, 1.31074260e-01, 1.08378312e01],
+            [8.79182622e-01, 4.45474832e-01, 1.09849294e01],
+            [7.26668838e-01, 9.74937759e-01, 1.05272224e01],
+            [4.05964529e-01, 1.45939524e-01, 1.09852591e01],
+            [2.10202133e-01, 9.20279331e-01, 1.06021266e01],
+            [7.05832473e-01, 9.51319508e-01, 1.09945602e01],
+            [7.78805359e-01, 6.72775055e-01, 1.03208115e01],
+            [5.44243955e-01, 5.63471403e-01, 1.09625371e01],
+            [8.45600648e-01, 9.05625429e-01, 1.06477723e01],
+            [9.34692923e-01, 9.70997998e-01, 1.05067462e01],
+            [9.57848052e-01, 4.33603497e-01, 1.04114712e01],
+            [8.09856480e-01, 5.51234361e-01, 1.07861471e01],
+            [3.52087564e-01, 4.35030132e-01, 1.02227394e01],
+            [4.26459999e-01, 4.04590274e-02, 1.00624838e01],
+            [6.40711056e-01, 7.64628524e-02, 1.06025149e01],
+            [4.50498913e-01, 6.60774849e-01, 1.03881413e01],
+            [3.61705605e-01, 1.01651959e-01, 1.08800255e01],
+            [4.07986797e-01, 9.62831662e-01, 1.09469213e01],
+        ]
+    )
+
+    r0 = 2
+    h0 = 1e-4
+    i0 = 1
+    src1 = magpy.magnet.Cylinder(
+        polarization=(0, 0, i0 / h0 * 4 * np.pi / 10 * 1e-6), dimension=(r0, h0)
+    )
+    src2 = magpy.current.Circle(current=i0, diameter=r0)
+
+    H1 = src1.getH(pos_obs)
+    H2 = src2.getH(pos_obs)
+
+    np.testing.assert_allclose(H1, H2)
+
+
+def test_Polyline_vs_Circle():
+    """show that line prodices the same as circular"""
+
+    # finely approximated loop by lines
+    ts = np.linspace(0, 2 * np.pi, 10000)
+    verts = np.array([(np.cos(t), np.sin(t), 0) for t in ts])
+    ps = verts[:-1]
+    pe = verts[1:]
+
+    # positions
+    ts = np.linspace(-3, 3, 2)
+    po = np.array([(x, y, z) for x in ts for y in ts for z in ts])
+
+    # field from line currents
+    Bls = []
+    for p in po:
+        Bl = magpy.getB("Polyline", p, current=1, segment_start=ps, segment_end=pe)
+        Bls += [np.sum(Bl, axis=0)]
+    Bls = np.array(Bls)
+
+    # field from current loop
+    src = magpy.current.Circle(current=1, diameter=2)
+    Bcs = src.getB(po)
+
+    np.testing.assert_allclose(Bls, Bcs, rtol=1e-05, atol=1e-08)
+
+
+def test_Polyline_vs_Infinite():
+    """compare line current result vs analytical solution to infinite Polyline"""
+
+    pos_obs = np.array([(1.0, 2, 3), (-3, 2, -1), (2, -1, -4)])
+
+    def Binf(i0, pos):
+        """field of inf line current on z-axis"""
+        x, y, _ = pos
+        r = np.sqrt(x**2 + y**2)
+        e_phi = np.array([-y, x, 0])
+        e_phi = e_phi / np.linalg.norm(e_phi)
+        mu0 = 4 * np.pi * 1e-7
+        return i0 * mu0 / 2 / np.pi / r * e_phi * 1000 * 1000
+
+    ps = (0, 0, -1000000)
+    pe = (0, 0, 1000000)
+    Bls, Binfs = [], []
+    for p in pos_obs:
+        Bls += [magpy.getB("Polyline", p, current=1, segment_start=ps, segment_end=pe)]
+        Binfs += [Binf(1, p)]
+    Bls = np.array(Bls)
+    Binfs = np.array(Binfs) * 1e-6
+
+    np.testing.assert_allclose(Bls, Binfs)
diff --git a/tests/test_scaling.py b/tests/test_scaling.py
new file mode 100644
index 000000000..c61abfc76
--- /dev/null
+++ b/tests/test_scaling.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import numpy as np
+
+import magpylib as magpy
+
+
+def test_scaling_loop():
+    """
+    The field of a current loop must satisfy
+    B(i0,d,x,y,z) = B(a*i0,a*d,a*x,a*y,a*z)
+    """
+    c1 = magpy.current.Circle(current=123, diameter=10)
+    B1 = c1.getB([1, 2, 3])
+    c2 = magpy.current.Circle(current=1230, diameter=100)
+    B2 = c2.getB([10, 20, 30])
+    c3 = magpy.current.Circle(current=12300, diameter=1000)
+    B3 = c3.getB([100, 200, 300])
+    c4 = magpy.current.Circle(current=123000, diameter=10000)
+    B4 = c4.getB([1000, 2000, 3000])
+
+    np.testing.assert_allclose(B1, B2)
+    np.testing.assert_allclose(B1, B3)
+    np.testing.assert_allclose(B1, B4)
diff --git a/tests/test_sources/test_currents/test_Circular.py b/tests/test_sources/test_currents/test_Circular.py
deleted file mode 100644
index be3424fb5..000000000
--- a/tests/test_sources/test_currents/test_Circular.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from magpylib.source import current
-from numpy import isnan, array
-import pytest
-
-
-def test_CircularNegDimError():
-    with pytest.raises(AssertionError):
-        current.Circular(5, dim=-1)
-
-
-def test_CircularGetB():
-    erMsg = "Results from getB are unexpected"
-    # Expected results for this input
-    mockResults = array([-0.11843504, -0.11843504,  0.4416876])
-
-    # Input
-    cur = 6
-    dim = 9
-    pos = (2, 2, 2)
-    fieldPos = (.5, .5, 5)
-
-    # Run
-    pm = current.Circular(cur, dim, pos)
-    result = pm.getB(fieldPos)
-
-    rounding = 4  # Round for floating point error
-    for i in range(3):
-        assert round(result[i], rounding) == round(
-            mockResults[i], rounding), erMsg
-
-
-def test_CircularLine_rotation_GetB():
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-
-    def applyRotAndReturnB(arg, obj):
-        obj.rotate(arg[1], arg[2], arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2, 3, 4], 36, (1, 2, 3), (.1, .2, .3)],  # Position, Angle, Axis, Anchor
-                 [[-3, -4, -5], -366, [3, -2, -1], [-.5, -.6, .7]],
-                 [[-1, -3, -5], pi, [0, 0, 0.0001], [-.5, -2, .7]],
-                 [[2, 3, 4], 36, [1, 2, 3], [.1, .2, .3]],
-                 [[-3, -4, -5], -124, [3, -2, -1], [-.5, -.6, .7]],
-                 [[-1, -3, -5], 275, [-2, -2, 4], [0, 0, 0]]]
-    mockResults = [[0.524354, 0.697093, 2.587274],
-                   [0.01134, 0.028993, 0.010118],
-                   [0.005114, 0.04497, 0.023742],
-                   [0.857106, -0.01403, 1.168066],
-                   [-0.036555, -0.239513, 0.038604],
-                   [-0.009827, -0.027402, 0.018106], ]
-
-    cur = 69
-    pos = [2, 2, 2]
-    dim = 2
-    pm = current.Circular(cur, dim, pos)
-
-    results = [applyRotAndReturnB(arg, pm) for arg in arguments]
-    rounding = 3
-    for i in range(0, len(mockResults)):
-        for j in range(0, 3):
-            assert round(mockResults[i][j], rounding) == round(
-                results[i][j], rounding), errMsg
-
-
-def test_CircularLineGetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-
-    def applyRotationAndReturnStatus(arg, obj):
-        obj.rotate(arg[0], arg[1], arg[2])
-        result = {"cur": obj.current,
-                  "pos": obj.position,
-                  "ang": obj.angle,
-                  "axi": obj.axis,
-                  "dim": obj.dimension, }
-        return result
-
-    arguments = [[36, (1, 2, 3), (.1, .2, .3)],
-                 [-366, [3, -2, -1], [-.5, -.6, .7]],
-                 [pi, [0, 0, 0.0001], [-.5, -2, .7]]]
-    mockResults = [{'cur': 0.69, 'pos': array([1.46754927, 2.57380229, 1.79494871]),
-                    'ang': 36.00000000000002, 'axi': array([0.26726124, 0.53452248, 0.80178373]), 'dim': 2.0},
-                   {'cur': 0.69, 'pos': array([1.4274764, 2.70435404, 1.41362661]),
-                    'ang': 321.8642936876839, 'axi': array([-0.14444227, -0.62171816, -0.76980709]), 'dim': 2.0},
-                   {'cur': 0.69, 'pos': array([1.16676385, 2.80291687, 1.41362661]),
-                    'ang': 319.3981749889049, 'axi': array([-0.11990803, -0.58891625, -0.79924947]), 'dim': 2.0}, ]
-    cur = 0.69
-    pos = [2, 2, 2]
-    dim = 2
-    pm = current.Circular(cur, dim, pos)
-
-    results = [applyRotationAndReturnStatus(arg, pm,) for arg in arguments]
-    print(results)
-    rounding = 4  # Round for floating point error
-    for i in range(0, len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['axi'][j], rounding) == round(
-                mockResults[i]['axi'][j], rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j], rounding) == round(
-                mockResults[i]['pos'][j], rounding), erMsg
-
-        assert round(results[i]['dim'], rounding) == round(
-            mockResults[i]['dim'], rounding), erMsg
-        assert round(results[i]['cur'], rounding) == round(
-            mockResults[i]['cur'], rounding), erMsg
-        assert round(results[i]['ang'], rounding) == round(
-            mockResults[i]['ang'], rounding), erMsg
-
-
-def test_CurrentGetBAngle():
-    erMsg = "Results from getB are unexpected"
-    # Expected results for this input
-    mockResults = (0.00509327,  0.00031343, -0.0385829)
-
-    # Input
-    curr = 2.45
-    dim = 3.1469
-    pos = (4.4, 5.24, 0.5)
-    angle = 45
-    fieldPos = [.5, 5, .35]
-
-    # Run
-    pm = current.Circular(curr, dim, pos, angle)
-    result = pm.getB(fieldPos)
-
-    rounding = 4  # Round for floating point error
-    for i in range(3):
-        assert round(result[i], rounding) == round(
-            mockResults[i], rounding), erMsg
-
-
-def test_ToString():
-    curr = 2.45
-    dimension = 3.1469
-    position = (4.4, 5.24, 0.5)
-    angle = 45.0
-    axis = [0.2, 0.61, 1.0]
-    expected = "type: {} \n current: {}  \n dimension: d: {} \n position: x: {}, y: {}, z: {} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format(
-        "current.Circular", curr, dimension, *position, angle, *axis)
-
-    myCircular = current.Circular(curr, dimension, position, angle, axis)
-
-    result = myCircular.__repr__()
-    assert result == expected
-
-    
\ No newline at end of file
diff --git a/tests/test_sources/test_currents/test_Line.py b/tests/test_sources/test_currents/test_Line.py
deleted file mode 100644
index 280132e83..000000000
--- a/tests/test_sources/test_currents/test_Line.py
+++ /dev/null
@@ -1,159 +0,0 @@
-from magpylib.source.current import Line
-from magpylib.source import current
-from numpy import isnan, array
-import pytest
-
-
-def test_LineNumpyArray():
-    # Test some valid variations of numpy arrays for Line vertices.
-    cur = 6
-    pos = (9, 2, 4)
-    vertices = [array([0, 0, 0]), array([4, 6, 2]), array([20, 3, 6])]
-    current.Line(cur, vertices, pos)
-    vertices = array([[0, 0, 0], [4, 6, 2], [20, 3, 6]])
-    current.Line(cur, vertices, pos)
-
-
-def test_LineGetB():
-    # Test a single getB calculation.
-    erMsg = "Results from getB are unexpected"
-    # Expected 3 results for this input
-    mockResults = array([0.00653909, -0.01204138,  0.00857173])
-
-    cur = 6
-    vertices = [[0, 0, 0], [4, 6, 2], [20, 3, 6]]
-    pos = (9, 2, 4)
-    fieldPos = (.5, .5, 5)
-
-    pm = current.Line(cur, vertices, pos)
-    result = pm.getB(fieldPos)
-
-    rounding = 4  # Round for floating point error
-    for i in range(3):
-        assert round(result[i], rounding) == round(
-            mockResults[i], rounding), erMsg
-
-
-def test_Line_rotation_GetB():
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-
-    def applyRotAndReturnB(arg, obj):
-        obj.rotate(arg[1], arg[2], arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2, 3, 4], 36, (1, 2, 3), (.1, .2, .3)],  # Position, Angle, Axis, Anchor
-                 [[-3, -4, -5], -366, [3, -2, -1], [-.5, -.6, .7]],
-                 [[-1, -3, -5], pi, [0, 0, 0.0001], [-.5, -2, .7]],
-                 [[2, 3, 4], 36, [1, 2, 3], [.1, .2, .3]],
-                 [[-3, -4, -5], -124, [3, -2, -1], [-.5, -.6, .7]],
-                 [[-1, -3, -5], 275, [-2, -2, 4], [0, 0, 0]]]
-    mockResults = [[0.020356, -0.052759, 0.139554],
-                   [-0.002636, 0.007098, 0.002922],
-                   [-0.007704, 0.01326, 0.003852],
-                   [0.101603, -0.004805, 0.073423],
-                   [-0.018195, -0.017728, -0.00885],
-                   [-0.010693, -0.004727, -0.002245], ]
-
-    cur = 0.69
-    pos = [2, 2, 2]
-
-    vertices = [[-4, -4, -3], [3.5, -3.5, -2], [3, 3, -1],
-                [-2.5, 2.5, 0], [-2, -2, 1], [1.5, -1.5, 2], [1, 1, 3]]
-
-    pm = current.Line(cur, vertices, pos)
-    results = [applyRotAndReturnB(arg, pm) for arg in arguments]
-
-    rounding = 3
-    for i in range(0, len(mockResults)):
-        for j in range(0, 3):
-            assert round(mockResults[i][j], rounding) == round(
-                results[i][j], rounding), errMsg
-
-
-def test_LineGetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-    from numpy import array_equal
-
-    def applyRotationAndReturnStatus(arg, obj):
-        obj.rotate(arg[0], arg[1], arg[2])
-        result = {"cur": obj.current,
-                  "pos": obj.position,
-                  "ang": obj.angle,
-                  "axi": obj.axis,
-                  "ver": obj.vertices, }
-        return result
-
-    arguments = [[36, (1, 2, 3), (.1, .2, .3)],
-                 [-366, [3, -2, -1], [-.5, -.6, .7]],
-                 [pi, [0, 0, 0.0001], [-.5, -2, .7]]]
-
-    vertices = [[-4, -4, -3], [3.5, -3.5, -2], [3, 3, -1],
-                [-2.5, 2.5, 0], [-2, -2, 1], [1.5, -1.5, 2], [1, 1, 3]]
-
-    mockResults = [{'cur': 0.69, 'pos': array([1.46754927, 2.57380229, 1.79494871]), 'ang': 36.00000000000002,
-                    'axi': array([0.26726124, 0.53452248, 0.80178373]), 'ver': vertices},
-                   {'cur': 0.69, 'pos': array([1.4274764, 2.70435404, 1.41362661]), 'ang': 321.8642936876839,
-                    'axi': array([-0.14444227, -0.62171816, -0.76980709]), 'ver':vertices},
-                   {'cur': 0.69, 'pos': array([1.16676385, 2.80291687, 1.41362661]), 'ang': 319.3981749889049,
-                    'axi': array([-0.11990803, -0.58891625, -0.79924947]), 'ver': vertices}]
-    cur = 0.69
-    pos = [2, 2, 2]
-
-    pm = current.Line(cur, vertices, pos)
-
-    results = [applyRotationAndReturnStatus(arg, pm,) for arg in arguments]
-    print(results)
-    rounding = 4  # Round for floating point error
-    for i in range(0, len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['axi'][j], rounding) == round(
-                mockResults[i]['axi'][j], rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j], rounding) == round(
-                mockResults[i]['pos'][j], rounding), erMsg
-        assert array_equal(results[i]['ver'], mockResults[i]['ver']), erMsg
-        assert round(results[i]['cur'], rounding) == round(
-            mockResults[i]['cur'], rounding), erMsg
-        assert round(results[i]['ang'], rounding) == round(
-            mockResults[i]['ang'], rounding), erMsg
-
-
-def test_LineGetBAngle():
-    # Create the line with a rotated position then verify with getB.
-    erMsg = "Results from getB are unexpected"
-    # Expected 3 results for this input
-    mockResults = (-0.00493354,  0.00980648,  0.0119963)
-
-    # Input
-    curr = 2.45
-    vertices = [[2, .35, 2], [10, 2, -4], [4, 2, 1], [102, 2, 7]]
-    pos = (4.4, 5.24, 0.5)
-    angle = 45
-    fieldPos = [.5, 5, .35]
-
-    # Run
-    pm = current.Line(curr, vertices, pos, angle)
-    result = pm.getB(fieldPos)
-
-    rounding = 4  # Round for floating point error
-    for i in range(3):
-        assert round(result[i], rounding) == round(
-            mockResults[i], rounding), erMsg
-
-
-
-def test_ToString():
-    curr = 2.45
-    vertices = [[2, .35, 2], [10, 2, -4], [4, 2, 1], [102, 2, 7]]
-    position = (4.4, 5.24, 0.5)
-    angle = 45.0
-    axis = [0.2,0.61, 1.0]
-    expected = "type: {} \n current: {} \n dimensions: vertices \n position: x: {}, y: {}, z: {} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format("current.Line", curr, *position, angle, *axis)
-
-    myLine = current.Line(curr, vertices, position, angle, axis)
-
-    result = myLine.__repr__()
-    assert result == expected
diff --git a/tests/test_sources/test_getB_vectorInput.py b/tests/test_sources/test_getB_vectorInput.py
deleted file mode 100644
index cc651a42a..000000000
--- a/tests/test_sources/test_getB_vectorInput.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import numpy as np
-import time
-import magpylib as magpy
-
-POS = np.array([[1,2,3],[2,-3,4],[-3,4,5],[5,6,-7],[-3,-2,1],[-4,3,-2],[5,-4,-3],[-6,-5,-4]])
-
-s = magpy.source.current.Circular(curr=123,dim=2,pos=(2,-4,5),angle=23,axis=(.2,-5,3))
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
-
-VERT = POS*.333
-s = magpy.source.current.Line(curr=123,vertices=VERT)
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
-
-s = magpy.source.moment.Dipole(moment=(.1,-5,5.5),pos=(2,-4,5),angle=33,axis=(.2,-5,3))
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
-
-s = magpy.source.magnet.Box(mag=(33,22,111),dim=(3,2,1),pos=(2,-4,5),angle=33,axis=(.2,-5,3))
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
-
-s = magpy.source.magnet.Cylinder(mag=(33,22,111),dim=(3,1),pos=(2,-4,5),angle=33,axis=(.2,-5,3))
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
-
-s = magpy.source.magnet.Sphere(mag=(33,22,111),dim=.123,pos=(2,-4,5),angle=33,axis=(.2,-5,3))
-B = np.array([s.getB(p) for p in POS])
-Bv = s.getB(POS)
-err = np.amax(np.abs(Bv-B))
-assert err < 1e-12
\ No newline at end of file
diff --git a/tests/test_sources/test_magnets/test_Box.py b/tests/test_sources/test_magnets/test_Box.py
deleted file mode 100644
index 0ee0d51ab..000000000
--- a/tests/test_sources/test_magnets/test_Box.py
+++ /dev/null
@@ -1,150 +0,0 @@
-from magpylib.source import magnet
-from numpy import isnan, array
-import pytest 
-
-def test_BoxZeroMagError():
-    with pytest.raises(AssertionError):
-        magnet.Box(mag=[0,0,0],dim=[1,1,1])
-
-def test_BoxZeroDimError():
-    with pytest.raises(AssertionError):
-        magnet.Box(mag=[1,1,1],dim=[0,0,0])
-
-
-def test_BoxEdgeCase_rounding():
-    ## For now this returns NaN, may be an analytical edge case
-    ## Test the Methods in getB() before moving onto this
-    expectedResult = [ 1.90833281e-12, -4.06404209e-13,  5.72529193e-09]
-    pm = magnet.Box(mag=[0,0,1000],dim=[0.5,0.1,1],pos=[.25,.55,-1111])
-    result = pm.getB([.5,.5,5])
-    rounding=10
-    for i in range(0,3):
-        assert round(result[i],rounding)==round(expectedResult[i],rounding), "Rounding edge case is wrong"
-        
-
-def test_Box_rotation_GetB():
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-    def applyRotAndReturnB(arg,obj):
-        obj.rotate(arg[1],arg[2],arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2,3,4],36,(1,2,3),(.1,.2,.3)], #Position, Angle, Axis, Anchor
-                [[-3,-4,-5],-366, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5],pi, [0,0,0.0001],[-.5,-2,.7]],
-                [[2,3,4], 36, [1,2,3],[.1,.2,.3]],
-                [[-3,-4,-5], -124, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5], 275, [-2,-2,4],[0,0,0]] ]
-    mockResults = [ [3.042604, 5.947039, 4.764305],
-                    [0.868926, 1.003817, 1.445178],
-                    [0.500882, 1.20951, 2.481127],
-                    [0.471373, 6.661388, 4.280969],
-                    [-3.444099, 2.669495, -6.237409],
-                    [0.004294, 0.952229, 2.079012],]
-    mag=[6,7,8]
-    dim=[10,10,10]
-    pos=[2,2,2]
-    pm = magnet.Box(mag,dim,pos)
-    results = [applyRotAndReturnB(arg,pm) for arg in arguments]
-
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-    
-def test_BoxGetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-
-    def applyRotationAndReturnStatus(arg,obj):
-        obj.rotate(arg[0],arg[1],arg[2])
-        result = {  "mag": obj.magnetization, 
-                    "pos": obj.position, 
-                    "ang": obj.angle, 
-                    "axi": obj.axis, 
-                    "dim": obj.dimension}
-        return result
-
-    arguments = [ [36,(1,2,3),(.1,.2,.3)],
-                  [-366, [3,-2,-1],[-.5,-.6,.7]],
-                  [pi, [0,0,0.0001],[-.5,-2,.7]]]
-    mockResults = [{'mag': array([6., 7., 8.]), 'pos': array([1.46754927, 2.57380229, 1.79494871]),
-                    'ang': 36.00000000000002, 'axi': array([0.26726124, 0.53452248, 0.80178373]), 
-                    'dim': array([10., 10., 10.])}, 
-                   {'mag': array([6., 7., 8.]), 'pos': array([1.4274764 , 2.70435404, 1.41362661]), 
-                    'ang': 321.8642936876839, 'axi': array([-0.14444227, -0.62171816, -0.76980709]), 
-                    'dim': array([10., 10., 10.])}, 
-                    {'mag': array([6., 7., 8.]), 'pos': array([1.16676385, 2.80291687, 1.41362661]), 
-                    'ang': 319.3981749889049, 'axi': array([-0.11990803, -0.58891625, -0.79924947]), 
-                    'dim': array([10., 10., 10.])}]
-    mag=[6,7,8]
-    dim=[10,10,10]
-    pos=[2,2,2]
-    pm = magnet.Box(mag,dim,pos)
-
-    results = [applyRotationAndReturnStatus(arg,pm,) for arg in arguments]
-    print(results)
-    rounding = 4 ## Round for floating point error 
-    for i in range(0,len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['mag'][j],rounding)==round(mockResults[i]['mag'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['axi'][j],rounding)==round(mockResults[i]['axi'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j],rounding)==round(mockResults[i]['pos'][j],rounding), erMsg
-
-        assert round(results[i]['ang'],rounding)==round(mockResults[i]['ang'],rounding), erMsg
-
-def test_BoxGetB():
-    erMsg = "Results from getB are unexpected"
-    mockResults = ( 3.99074612, 4.67238469, 4.22419432) ## Expected 3 results for this input
-
-    # Input
-    mag=[6,7,8]
-    dim=[10,10,10]
-    pos=[2,2,2]
-
-
-
-    # Run
-    pm = magnet.Box(mag,dim,pos)
-    result = pm.getB([.5,.5,5])
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-def test_BoxGetBAngle():
-    erMsg = "Results from getB are unexpected"
-    mockResults = ( 0.08779447,  0.0763171,  -0.11471596 ) ## Expected 3 results for this input
-
-    # Input
-    mag=(0.2,32.5,5.3)
-    dim=(1,2.4,5)
-    pos=(1,0.2,3)
-    axis=[0.2,1,0]
-    angle=90
-    fieldPos=[5,5,.35]
-
-    # Run
-    pm = magnet.Box(mag,dim,pos,angle,axis)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-
-def test_ToString():
-    magnetization=(0.2,32.5,5.3)
-    dimension=(1.0,2.4,5.0)
-    position=(1.0,0.2,3.0)
-    axis=[0.2,1.0,0.0]
-    angle=90.0
-    expected="type: {} \n magnetization: x: {}, y: {}, z: {} \n dimensions: a: {}, b: {}, c: {} \n position: x: {}, y:{}, z: {} \n angle: {} Degrees \n axis: x: {}, y: {}, z:{}".format("magnet.Box", *magnetization, *dimension, *position, angle, *axis)
-
-    myBox = magnet.Box(magnetization, dimension, position, angle, axis)
-
-    result = myBox.__repr__()
-    assert result == expected
\ No newline at end of file
diff --git a/tests/test_sources/test_magnets/test_Cylinder.py b/tests/test_sources/test_magnets/test_Cylinder.py
deleted file mode 100644
index 9ed5b100a..000000000
--- a/tests/test_sources/test_magnets/test_Cylinder.py
+++ /dev/null
@@ -1,138 +0,0 @@
-from magpylib.source import magnet
-from numpy import isnan, array
-import pytest 
-
-def test_CylinderZeroMagError():
-    with pytest.raises(AssertionError):
-        magnet.Cylinder(mag=(0,0,0),dim=(1,1))
-
-def test_CylinderZeroDimError():
-    with pytest.raises(AssertionError):
-        magnet.Cylinder(mag=(1,1,1),dim=(0,0))
-
-def test_Cylinder_rotation_GetB(): 
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-    def applyRotAndReturnB(arg,obj):
-        obj.rotate(arg[1],arg[2],arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2,3,4],36,(1,2,3),(.1,.2,.3)], #Position, Angle, Axis, Anchor
-                [[-3,-4,-5],-366, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5],pi, [0,0,0.0001],[-.5,-2,.7]],
-                [[2,3,4], 36, [1,2,3],[.1,.2,.3]],
-                [[-3,-4,-5], -124, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5], 275, [-2,-2,4],[0,0,0]] ]
-    mockResults = [ [2.541652, 7.258253, 2.985087],
-                    [0.287798, 0.311855, 0.470142],
-                    [0.212632, 0.382307, 0.787474],
-                    [2.16498, -1.070064, 1.745844],
-                    [-5.858558, 1.331839, -6.035032],
-                    [-0.173693, 0.283437, 0.999618],]
-    mag=[6,7,8]
-    dim=[10,5]
-    pos=[2,2,2]
-    pm = magnet.Cylinder(mag,dim,pos)
-    results = [applyRotAndReturnB(arg,pm) for arg in arguments]
-
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-    
-def test_Cylinder_GetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-
-    def applyRotationAndReturnStatus(arg,obj):
-        obj.rotate(arg[0],arg[1],arg[2])
-        result = {  "mag": obj.magnetization, 
-                    "pos": obj.position, 
-                    "ang": obj.angle, 
-                    "axi": obj.axis, 
-                    "dim": obj.dimension}
-        return result
-
-    arguments = [ [36,(1,2,3),(.1,.2,.3)],
-                  [-366, [3,-2,-1],[-.5,-.6,.7]],
-                  [pi, [0,0,0.0001],[-.5,-2,.7]]]
-    mockResults = [ {'mag': array([6., 7., 8.]), 'pos': array([1.46754927, 2.57380229, 1.79494871]), 
-                     'ang': 36.00000000000002, 'axi': array([0.26726124, 0.53452248, 0.80178373]), 
-                     'dim': array([10.,  5.])},
-                    {'mag': array([6., 7., 8.]), 'pos': array([1.4274764 , 2.70435404, 1.41362661]), 
-                     'ang': 321.8642936876839, 'axi': array([-0.14444227, -0.62171816, -0.76980709]), 
-                     'dim': array([10.,  5.])},
-                    {'mag': array([6., 7., 8.]), 'pos': array([1.16676385, 2.80291687, 1.41362661]), 
-                     'ang': 319.3981749889049, 'axi': array([-0.11990803, -0.58891625, -0.79924947]), 
-                     'dim': array([10.,  5.])},]
-    mag=[6,7,8]
-    dim=[10,5]
-    pos=[2,2,2]
-    pm = magnet.Cylinder(mag,dim,pos)
-
-    results = [applyRotationAndReturnStatus(arg,pm,) for arg in arguments]
-    print(results)
-    rounding = 4 ## Round for floating point error 
-    for i in range(0,len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['mag'][j],rounding)==round(mockResults[i]['mag'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['axi'][j],rounding)==round(mockResults[i]['axi'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j],rounding)==round(mockResults[i]['pos'][j],rounding), erMsg
-            
-        assert round(results[i]['ang'],rounding)==round(mockResults[i]['ang'],rounding), erMsg
-
-def test_CylinderGetB():
-    erMsg = "Results from getB are unexpected"
-    mockResults = array([ 0.62431573,  0.53754927, -0.47024376]) ## Expected results for this input
-
-    # Input 
-    mag=(6,7,8)
-    dim=(2,9)
-    pos=(2,2,2)
-    fieldPos = (.5,.5,5)
-
-    # Run
-    pm = magnet.Cylinder(mag,dim,pos)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-def test_CylinderGetBAngle():
-    erMsg = "Results from getB are unexpected"
-    mockResults = ( 0.01576884,  0.01190684, -0.01747232 ) ## Expected 3 results for this input
-
-    # Input
-    mag=(0.2,32.5,5.3)
-    dim=(1,2.4)
-    pos=(1,0.2,3)
-    axis=[0.2,1,0]
-    angle=90
-    fieldPos=[5,5,.35]
-
-    # Run
-    pm = magnet.Cylinder(mag,dim,pos,angle,axis)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-
-
-def test_ToString():
-    magnetization=(0.2,32.5,5.3)
-    dimension=(2.0,9.0)
-    position=(1.0,0.2,3.0)
-    axis=[0.2,1.0,0.0]
-    angle=90.0
-    expected="type: {} \n magnetization: x: {}, y: {}, z: {} \n dimensions: d: {}, h: {} \n position: x: {}, y:{}, z: {} \n angle: {} \n axis: x: {}, y: {}, z:{}".format("magnet.Cylinder", *magnetization, *dimension, *position, angle, *axis)
-
-    myCylinder = magnet.Cylinder(magnetization, dimension, position, angle, axis)
-
-    result = myCylinder.__repr__()
-    assert result == expected
\ No newline at end of file
diff --git a/tests/test_sources/test_magnets/test_Sphere.py b/tests/test_sources/test_magnets/test_Sphere.py
deleted file mode 100644
index 61cb92aa8..000000000
--- a/tests/test_sources/test_magnets/test_Sphere.py
+++ /dev/null
@@ -1,136 +0,0 @@
-from magpylib.source.magnet import Sphere
-from magpylib.source import magnet
-from numpy import isnan, array
-import pytest 
-
-def test_SphereZeroMagError():
-    with pytest.raises(AssertionError):
-        magnet.Sphere(mag=[0,0,0],dim=1)
-
-def test_SphereZeroDimError():
-    with pytest.raises(AssertionError):
-        magnet.Sphere(mag=[1,1,1],dim=0)
-
-def test_SphereGetB():
-    erMsg = "Results from getB are unexpected"
-    mockResults = array([-0.05040102, -0.05712116, -0.03360068]) ## Expected 3 results for this input
-    
-    # Input
-    mag=[6,7,8]
-    dim=2
-    pos=[2,2,2]
-    fieldPos = [.5,.5,5]
-
-    # Run
-    pm = magnet.Sphere(mag,dim,pos)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-def test_SphereGetBAngle():
-    erMsg = "Results from getB are unexpected"
-    mockResults = (-0.00047774, -0.00535384, -0.00087997) ## Expected 3 results for this input
-
-    # Input
-    mag=(0.2,32.5,5.3)
-    dim=1
-    pos=(1,0.2,3)
-    axis=[0.2,.61,1]
-    angle=89
-    fieldPos=[5,5,.35]
-
-    # Run
-    pm = magnet.Sphere(mag,dim,pos,angle,axis)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-def test_Box_rotation_GetB():
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-    def applyRotAndReturnB(arg,obj):
-        obj.rotate(arg[1],arg[2],arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2,3,4],36,(1,2,3),(.1,.2,.3)], #Position, Angle, Axis, Anchor
-                [[-3,-4,-5],-366, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5],pi, [0,0,0.0001],[-.5,-2,.7]],
-                [[2,3,4], 36, [1,2,3],[.1,.2,.3]],
-                [[-3,-4,-5], -124, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5], 275, [-2,-2,4],[0,0,0]] ]
-    mockResults = [ [0.057055, -0.081669, 0.557462],
-                    [0.003315, 0.004447, 0.004952],
-                    [0.002031, 0.006438, 0.008955],
-                    [0.145866, -0.118869, 0.125958],
-                    [0.023151, -0.029514, 0.013329],
-                    [-0.001438, 0.003102, 0.009031],]
-    mag=[6,7,8]
-    dim=2
-    pos=[2,2,2]
-    pm = magnet.Sphere(mag,dim,pos)
-    results = [applyRotAndReturnB(arg,pm) for arg in arguments]
-
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-    
-def test_BoxGetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-
-    def applyRotationAndReturnStatus(arg,obj):
-        obj.rotate(arg[0],arg[1],arg[2])
-        result = {  "mag": obj.magnetization, 
-                    "pos": obj.position, 
-                    "ang": obj.angle, 
-                    "axi": obj.axis, 
-                    "dim": obj.dimension}
-        return result
-
-    arguments = [ [36,(1,2,3),(.1,.2,.3)],
-                  [-366, [3,-2,-1],[-.5,-.6,.7]],
-                  [pi, [0,0,0.0001],[-.5,-2,.7]]]
-    mockResults = [     {'mag': array([6., 7., 8.]), 'pos': array([1.46754927, 2.57380229, 1.79494871]), 'ang': 36.00000000000002, 
-                         'axi': array([0.26726124, 0.53452248, 0.80178373]), 'dim': 2.0},
-                        {'mag': array([6., 7., 8.]), 'pos': array([1.4274764 , 2.70435404, 1.41362661]), 'ang': 321.8642936876839, 
-                         'axi': array([-0.14444227, -0.62171816, -0.76980709]), 'dim': 2.0},
-                        {'mag': array([6., 7., 8.]), 'pos': array([1.16676385, 2.80291687, 1.41362661]), 'ang': 319.3981749889049, 
-                         'axi': array([-0.11990803, -0.58891625, -0.79924947]), 'dim': 2.0},]
-    mag = [6,7,8]
-    dim = 2
-    pos = [2,2,2]
-    pm = magnet.Sphere(mag,dim,pos)
-
-    results = [applyRotationAndReturnStatus(arg,pm,) for arg in arguments]
-    print(results)
-    rounding = 4 ## Round for floating point error 
-    for i in range(0,len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['mag'][j],rounding)==round(mockResults[i]['mag'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['axi'][j],rounding)==round(mockResults[i]['axi'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j],rounding)==round(mockResults[i]['pos'][j],rounding), erMsg
-
-        assert round(results[i]['ang'],rounding)==round(mockResults[i]['ang'],rounding), erMsg
-
-
-
-def test_ToString():
-    magnetization=(0.2,32.5,5.3)
-    dimension=1.0
-    position=(1.0,0.2,3.0)
-    axis=[0.2,.61,1.0]
-    angle=89.0
-    expected="type: {} \n magnetization: x: {}, y: {}, z: {}mT \n dimensions: d: {} \n position: x: {}, y:{}, z: {} \n angle: {} Degrees \n axis: x: {}, y: {}, z:{}".format("magnet.Sphere", *magnetization, dimension, *position, angle, *axis)
-
-    mySphere = Sphere(magnetization, dimension, position, angle, axis)
-
-    result = mySphere.__repr__()
-    assert result == expected 
\ No newline at end of file
diff --git a/tests/test_sources/test_moments/test_Dipole.py b/tests/test_sources/test_moments/test_Dipole.py
deleted file mode 100644
index aa7187d26..000000000
--- a/tests/test_sources/test_moments/test_Dipole.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from magpylib.source.moment import Dipole
-from numpy import isnan, array
-import pytest 
-
-
-def test_Dipole_rotation_GetB():
-    errMsg = "Results from getB are unexpected"
-    from numpy import pi
-    # Setup
-    def applyRotAndReturnB(arg,obj):
-        obj.rotate(arg[1],arg[2],arg[3])
-        return obj.getB(arg[0])
-
-    arguments = [[[2,3,4],36,(1,2,3),(.1,.2,.3)], #Position, Angle, Axis, Anchor
-                [[-3,-4,-5],-366, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5],pi, [0,0,0.0001],[-.5,-2,.7]],
-                [[2,3,4], 36, [1,2,3],[.1,.2,.3]],
-                [[-3,-4,-5], -124, [3,-2,-1],[-.5,-.6,.7]],
-                [[-1,-3,-5], 275, [-2,-2,4],[0,0,0]] ]
-    mockResults = [ [0.013621, -0.019497, 0.133084],
-                    [0.000791, 0.001062, 0.001182],
-                    [0.000485, 0.001537, 0.002138],
-                    [0.034823, -0.028378, 0.03007],
-                    [0.005527, -0.007046, 0.003182],
-                    [-0.000343, 0.000741, 0.002156],]
-    mag=[6,7,8]
-    pos=[2,2,2]
-    pm = Dipole(mag,pos)
-    results = [applyRotAndReturnB(arg,pm) for arg in arguments]
-
-    rounding = 4
-    for i in range(0,len(mockResults)):
-        for j in range(0,3):
-            assert round(mockResults[i][j],rounding)==round(results[i][j],rounding), errMsg
-    
-def test_DipoleGetB_rotation():
-    erMsg = "Results from getB are unexpected"
-    from numpy import pi
-
-    def applyRotationAndReturnStatus(arg,obj):
-        obj.rotate(arg[0],arg[1],arg[2])
-        result = {  "mom": obj.moment, 
-                    "pos": obj.position, 
-                    "ang": obj.angle, 
-                    "axi": obj.axis, }
-        return result
-
-    arguments = [ [36,(1,2,3),(.1,.2,.3)],
-                  [-366, [3,-2,-1],[-.5,-.6,.7]],
-                  [pi, [0,0,0.0001],[-.5,-2,.7]]]
-    mockResults = [{'mom': array([6., 7., 8.]), 'pos': array([1.46754927, 2.57380229, 1.79494871]), 
-                    'ang': 36.00000000000002, 'axi': array([0.26726124, 0.53452248, 0.80178373])},
-                   {'mom': array([6., 7., 8.]), 'pos': array([1.4274764 , 2.70435404, 1.41362661]), 
-                    'ang': 321.8642936876839, 'axi': array([-0.14444227, -0.62171816, -0.76980709])},
-                   {'mom': array([6., 7., 8.]), 'pos': array([1.16676385, 2.80291687, 1.41362661]), 
-                    'ang': 319.3981749889049, 'axi': array([-0.11990803, -0.58891625, -0.79924947])},]
-    mag=[6,7,8]
-    pos=[2,2,2]
-    pm = Dipole(mag,pos)
-
-    results = [applyRotationAndReturnStatus(arg,pm,) for arg in arguments]
-    print(results)
-    rounding = 4 ## Round for floating point error 
-    for i in range(0,len(mockResults)):
-        for j in range(3):
-            assert round(results[i]['mom'][j],rounding)==round(mockResults[i]['mom'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['axi'][j],rounding)==round(mockResults[i]['axi'][j],rounding), erMsg
-        for j in range(3):
-            assert round(results[i]['pos'][j],rounding)==round(mockResults[i]['pos'][j],rounding), erMsg
-
-        assert round(results[i]['ang'],rounding)==round(mockResults[i]['ang'],rounding), erMsg
-
-
-def test_DipoleGetB():
-    erMsg = "Results from getB are unexpected"
-    mockResults = array([ 1.23927518e-06,  6.18639685e-06, -1.67523560e-06]) ## Expected 3 results for this input
-
-    # Input
-    moment=[5,2,10]
-    pos=(24,51,22)
-    fieldPos = (.5,.5,5)
-
-    # Run
-    pm = Dipole(moment,pos)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-def test_DipoleGetBAngle():
-    erMsg = "Results from getB are unexpected"
-    mockResults = (-0.00836643 , 0.01346,   -0.01833964) ## Expected 3 results for this input
-
-    # Input
-    moment=(0.2,32.5,5.3)
-    pos=(1,0.2,3)
-    axis=[0.2,1,0]
-    angle=90
-    fieldPos=[.5,5,.35]
-
-    # Run
-    pm = Dipole(moment,pos,angle,axis)
-    result = pm.getB(fieldPos)
-
-    rounding = 4 ## Round for floating point error 
-    for i in range(3):
-        assert round(result[i],rounding)==round(mockResults[i],rounding), erMsg
-
-
-def test_ToString():
-    moment=(0.2,32.5,5.3)
-    position=(1.0,0.2,3.0)
-    axis=[0.2,1.0,0.0]
-    angle=90.0
-    expected="type: {} \n moment: x: {}, y: {}, z: {} \n position: x: {}, y: {}, z:{} \n angle: {}  \n axis: x: {}, y: {}, z: {}".format("moments.Dipole", *moment, *position, angle, *axis)
-
-    myDipole = Dipole(moment, position, angle, axis)
-
-    result = myDipole.__repr__()
-    assert result == expected
\ No newline at end of file
diff --git a/tests/test_tarcisBug.py b/tests/test_tarcisBug.py
deleted file mode 100644
index 4e995a3fc..000000000
--- a/tests/test_tarcisBug.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import magpylib as magpy
-import numpy
-import unittest
-
-def test_rounding_error():
-    ## This describes the rotation bug. 
-    ## Once it is fixed, this test will break.
-    I = 1
-    d = 10e+05
-    p0 = [0,0,0]
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[0,0,1],anchor=[0,0,0])
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-    #print(c.angle) #These turn out differently for both cases.
-    #print(c.axis)
-
-    result1 = c.getB(p0)
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-    #print(c.angle)
-    #print(c.axis)
-
-    result2 = c.getB(p0)
-    assert all(numpy.isclose(result1,result2)==True) is False
-
-
-def test_rounding_error_small_dimension():
-    ## This describes a case where the bug does not occur.
-    I = 1
-    d = 10e+03
-    p0 = [0,0,0]
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[0,0,1],anchor=[0,0,0])
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-
-    result1 = c.getB(p0)
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-
-    result2 = c.getB(p0)
-    assert all(numpy.isclose(result1,result2)==True)
-
-def test_rounding_error_large_dimension():
-    ## This describes a case where the bug does not occur.
-    I = 1
-    d = 10e+08
-    p0 = [0,0,0]
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[0,0,1],anchor=[0,0,0])
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-
-    result1 = c.getB(p0)
-
-    c = magpy.source.current.Circular(curr=I, dim=d,pos=[0,0,d/4])
-
-    c.rotate(90,[1,0,0],anchor=[0,0,0])
-
-    result2 = c.getB(p0)
-    assert all(numpy.isclose(result1,result2)==True)
-
diff --git a/tests/test_utility.py b/tests/test_utility.py
index c1c6b3f3a..c234ad44c 100644
--- a/tests/test_utility.py
+++ b/tests/test_utility.py
@@ -1,103 +1,90 @@
-from magpylib._lib.utility import checkDimensions, isPosVector,isDisplayMarker
-from magpylib._lib.utility import isSensor
-from numpy import array
-import pytest
-
-# -------------------------------------------------------------------------------
-def test_IsDisplayMarker_error():
-    marker1=[0,0,0]
-    marker2=[0,0,1]
-    marker3=[0,0,1,"hello world!"] 
-    marker4=[0,0,1,-1] # Should fail!
+from __future__ import annotations
 
-    markerList = [marker1,marker2,marker3,marker4]
-    with pytest.raises(AssertionError):
-        for marker in markerList:
-            assert isDisplayMarker(marker)
+import numpy as np
+import pytest
 
-# -------------------------------------------------------------------------------
-def test_IsDisplayMarker():
-    errMsg = "marker identifier has failed: "
-    marker1=[0,0,0]
-    marker2=[0,0,1]
-    marker3=[0,0,1,"hello world!"]
+import magpylib as magpy
+from magpylib._src.utility import add_iteration_suffix, check_duplicates, filter_objects
 
-    markerList = [marker1,marker2,marker3]
-    for marker in markerList:
-        assert isDisplayMarker(marker), errMsg + str(marker)
 
-# -------------------------------------------------------------------------------
-def test_checkDimensionZero():
-    # errMsg = "Did not raise all zeros Error"
-    with pytest.raises(AssertionError):
-        checkDimensions(3,dim=(.0,0,.0))
+def test_duplicates():
+    """test duplicate elimination and sorting"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    src_list = [pm1, pm2, pm1]
+    with pytest.warns(UserWarning):
+        src_list_new = check_duplicates(src_list)
+    assert src_list_new == [pm1, pm2], "duplicate elimination failed"
 
-    with pytest.raises(AssertionError):
-        checkDimensions(0,dim=[])
 
-# -------------------------------------------------------------------------------
-def test_checkDimensionMembers():
-    # errMsg = "Did not raise expected Value Error"
-    with pytest.raises(ValueError):
-        checkDimensions(3,dim=(3,'r',6))
+def test_filter_objects():
+    """tests elimination of unwanted types"""
+    pm1 = magpy.magnet.Cuboid(polarization=(1, 2, 3), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cylinder(polarization=(1, 2, 3), dimension=(1, 2))
+    sens = magpy.Sensor()
+    src_list = [pm1, pm2, sens]
+    with pytest.warns(UserWarning):
+        list_new = filter_objects(src_list, allow="sources")
+    assert list_new == [pm1, pm2], "Failed to eliminate sensor"
 
-# -------------------------------------------------------------------------------
-def test_checkDimensionSize():
-    errMsg = "Did not raise wrong dimension size Error"
-    with pytest.raises(AssertionError) as error:
-        checkDimensions(3,dim=(3,5,9,6))
-    assert error.type == AssertionError, errMsg
 
-# -------------------------------------------------------------------------------
-def test_checkDimensionReturn():
-    errMsg = "Wrong return dimension size"
-    result = checkDimensions(4,dim=(3,5,9,10))
-    assert len(result) == 4, errMsg
-    result = checkDimensions(3,dim=(3,5,9))
-    assert len(result) == 3, errMsg
-    result = checkDimensions(2,dim=(3,5))
-    assert len(result) == 2, errMsg
-    result = checkDimensions(1,dim=(3))
-    assert len(result) == 1, errMsg
-    
-# -------------------------------------------------------------------------------
-def test_isPosVector():
-    errMsg = "isPosVector returned unexpected False value"
-    position = [1,2,3]
-    assert isPosVector(position), errMsg
+def test_format_getBH_class_inputs():
+    """special case testing of different input formats"""
+    possis = [3, 3, 3]
+    sens = magpy.Sensor(position=(3, 3, 3))
+    pm1 = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    pm2 = magpy.magnet.Cuboid(polarization=(11, 22, 33), dimension=(1, 2, 3))
+    col = pm1 + pm2
 
-# -------------------------------------------------------------------------------
-def test_isPosVectorArray():
-    from numpy import array
-    errMsg = "isPosVector returned unexpected False value"
-    position = array([1,2,3])
-    assert isPosVector(position), errMsg
+    B1 = pm1.getB(possis)
+    B2 = pm1.getB(sens)
+    np.testing.assert_allclose(B1, B2, err_msg="pos_obs should give same as sens")
 
-# -------------------------------------------------------------------------------
-def test_isPosVectorArray2():
-    from numpy import array
-    errMsg = "isPosVector returned unexpected False value"
-    position = array([1,4,-24.242])
-    assert isPosVector(position), errMsg
+    B3 = pm1.getB(sens, sens)
+    B4 = pm1.getB([sens, sens])
+    B44 = pm1.getB((sens, sens))
+    np.testing.assert_allclose(
+        B3,
+        B4,
+        err_msg="sens,sens should give same as [sens,sens]",
+    )
+    np.testing.assert_allclose(
+        B3,
+        B44,
+        err_msg="sens,sens should give same as (sens,sens)",
+    )
 
-# -------------------------------------------------------------------------------
-def test_isSensor():
-    from magpylib._lib.classes.sensor import Sensor 
-    s = Sensor()
+    B1 = sens.getH(pm1) * 4
+    B2 = sens.getH(pm1, pm2, col, sumup=True)
+    B3 = sens.getH([col]) * 2
+    B4 = sens.getH([col, pm1, pm2], sumup=True)
 
-    assert isSensor(s)
+    np.testing.assert_allclose(
+        B1,
+        B2,
+        err_msg="src,src should give same as [src,src]",
+    )
+    np.testing.assert_allclose(
+        B1,
+        B3,
+        err_msg="src should give same as [src]",
+    )
+    np.testing.assert_allclose(
+        B1,
+        B4,
+        err_msg="src,src should give same as [src,src]",
+    )
 
-# -------------------------------------------------------------------------------
-def test_isPosVectorArgs():
-    from numpy import array
 
-    listOfArgs = [  [   [1,2,3],        #pos
-                    [0,0,1],        #MPos
-                    (180,(0,1,0)),],#Morientation
-                 [   [1,2,3],
-                     [0,1,0],
-                     (90,(1,0,0)),],
-                 [   [1,2,3],
-                     [1,0,0],
-                     (255,(0,1,0)),],]
-    assert any(isPosVector(a)==False for a in listOfArgs)                 
+@pytest.mark.parametrize(
+    ("name", "expected"),
+    [
+        ("col", "col_01"),
+        ("col_", "col_01"),
+        ("col1", "col2"),
+        ("col_02", "col_03"),
+    ],
+)
+def test_add_iteration_suffix(name, expected):
+    """check if iteration suffix works correctly"""
+    assert add_iteration_suffix(name) == expected
diff --git a/tests/test_vs_mag2.py b/tests/test_vs_mag2.py
new file mode 100644
index 000000000..226bfc129
--- /dev/null
+++ b/tests/test_vs_mag2.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+import numpy as np
+
+import magpylib as magpy
+
+# GENERATE TESTDATA ---------------------------------------
+# import pickle
+# import magpylib as magpy
+
+# # linear motion from (0,0,0) to (3,-3,3) in 100 steps
+# pm = magpy.source.magnet.Cuboid(polarization=(111,222,333), dimension=(1,2,3))
+# B1 = np.array([pm.getB((i,-i,i)) for i in np.linspace(0,3,100)])
+
+# # rotation (pos_obs around magnet) from 0 to 444 deg, starting pos_obs at (0,3,0) about 'z'
+# pm = magpy.source.magnet.Cuboid(polarization=(111,222,333), dimension=(1,2,3))
+# possis = [(3*np.sin(t/180*np.pi),3*np.cos(t/180*np.pi),0) for t in np.linspace(0,444,100)]
+# B2 = np.array([pm.getB(p) for p in possis])
+
+# # spiral (magnet around pos_obs=0) from 0 to 297 deg, about 'z' in 100 steps
+# pm = magpy.source.magnet.Cuboid(polarization=(111,222,333), dimension=(1,2,3), pos=(3,0,0))
+# B = []
+# for i in range(100):
+#     B += [pm.getB((0,0,0))]
+#     pm.rotate(3,(0,0,1),anchor=(0,0,0))
+#     pm.move((0,0,.1))
+# B3 = np.array(B)
+
+# B = np.array([B1,B2,B3])
+# pickle.dump(B, open('testdata_vs_mag2.p', 'wb'))
+# -------------------------------------------------------------
+
+
+def test_vs_mag2_linear():
+    """test against magpylib v2"""
+    with Path("tests/testdata/testdata_vs_mag2.p").resolve().open("rb") as f:
+        data = pickle.load(f)[0]
+    poso = [(t, -t, t) for t in np.linspace(0, 3, 100)]
+    pm = magpy.magnet.Cuboid(polarization=(111, 222, 333), dimension=(1, 2, 3))
+
+    B = magpy.getB(pm, poso)
+    np.testing.assert_allclose(B, data, err_msg="vs mag2 - linear")
+
+
+def test_vs_mag2_rotation():
+    """test against magpylib v2"""
+    with Path("tests/testdata/testdata_vs_mag2.p").resolve().open("rb") as f:
+        data = pickle.load(f)[1]
+    pm = magpy.magnet.Cuboid(polarization=(111, 222, 333), dimension=(1, 2, 3))
+    possis = [
+        (3 * np.sin(t / 180 * np.pi), 3 * np.cos(t / 180 * np.pi), 0)
+        for t in np.linspace(0, 444, 100)
+    ]
+    B = pm.getB(possis)
+    np.testing.assert_allclose(B, data, err_msg="vs mag2 - rot")
+
+
+def test_vs_mag2_spiral():
+    """test against magpylib v2"""
+    with Path("tests/testdata/testdata_vs_mag2.p").resolve().open("rb") as f:
+        data = pickle.load(f)[2]
+    pm = magpy.magnet.Cuboid(
+        polarization=(111, 222, 333), dimension=(1, 2, 3), position=(3, 0, 0)
+    )
+
+    angs = np.linspace(0, 297, 100)
+    pm.rotate_from_angax(angs, "z", anchor=0, start=0)
+    possis = np.linspace((0, 0, 0.1), (0, 0, 9.9), 99)
+    pm.move(possis, start=1)
+    B = pm.getB((0, 0, 0))
+    np.testing.assert_allclose(B, data, err_msg="vs mag2 - rot")
+
+
+def test_vs_mag2_line():
+    """test line current vs mag2 results"""
+    Btest = np.array([1.47881931, -1.99789688, 0.2093811]) * 1e-6
+
+    src = magpy.current.Polyline(
+        current=10,
+        vertices=[(0, -5, 0), (0, 5, 0), (3, 3, 3), (-1, -2, -3), (1, 1, 1), (2, 3, 4)],
+    )
+    B = src.getB([1, 2, 3])
+
+    np.testing.assert_allclose(Btest, B)
diff --git a/tests/testdata/testdata_Collection.p b/tests/testdata/testdata_Collection.p
new file mode 100644
index 000000000..3392d8910
Binary files /dev/null and b/tests/testdata/testdata_Collection.p differ
diff --git a/tests/testdata/testdata_Collection_setter.npy b/tests/testdata/testdata_Collection_setter.npy
new file mode 100644
index 000000000..577593cc8
Binary files /dev/null and b/tests/testdata/testdata_Collection_setter.npy differ
diff --git a/tests/testdata/testdata_Cuboid.p b/tests/testdata/testdata_Cuboid.p
new file mode 100644
index 000000000..e0be44efc
Binary files /dev/null and b/tests/testdata/testdata_Cuboid.p differ
diff --git a/tests/testdata/testdata_Sphere.p b/tests/testdata/testdata_Sphere.p
new file mode 100644
index 000000000..73cd7212c
Binary files /dev/null and b/tests/testdata/testdata_Sphere.p differ
diff --git a/tests/testdata/testdata_compound_setter_cases.npy b/tests/testdata/testdata_compound_setter_cases.npy
new file mode 100644
index 000000000..f64200a92
Binary files /dev/null and b/tests/testdata/testdata_compound_setter_cases.npy differ
diff --git a/tests/testdata/testdata_cy_cases.npy b/tests/testdata/testdata_cy_cases.npy
new file mode 100644
index 000000000..7c1cfc890
Binary files /dev/null and b/tests/testdata/testdata_cy_cases.npy differ
diff --git a/tests/testdata/testdata_el3.npy b/tests/testdata/testdata_el3.npy
new file mode 100644
index 000000000..7714c753f
Binary files /dev/null and b/tests/testdata/testdata_el3.npy differ
diff --git a/tests/testdata/testdata_el3_angle.npy b/tests/testdata/testdata_el3_angle.npy
new file mode 100644
index 000000000..1e2b49c59
Binary files /dev/null and b/tests/testdata/testdata_el3_angle.npy differ
diff --git a/tests/testdata/testdata_femDat_cylinder_tile2.npy b/tests/testdata/testdata_femDat_cylinder_tile2.npy
new file mode 100644
index 000000000..664ff5e3a
Binary files /dev/null and b/tests/testdata/testdata_femDat_cylinder_tile2.npy differ
diff --git a/tests/testdata/testdata_field_BH_cuboid.p b/tests/testdata/testdata_field_BH_cuboid.p
new file mode 100644
index 000000000..fec816615
Binary files /dev/null and b/tests/testdata/testdata_field_BH_cuboid.p differ
diff --git a/tests/testdata/testdata_field_BH_cylinder.p b/tests/testdata/testdata_field_BH_cylinder.p
new file mode 100644
index 000000000..2add8d347
Binary files /dev/null and b/tests/testdata/testdata_field_BH_cylinder.p differ
diff --git a/tests/testdata/testdata_full_cyl.npy b/tests/testdata/testdata_full_cyl.npy
new file mode 100644
index 000000000..e48198b9d
Binary files /dev/null and b/tests/testdata/testdata_full_cyl.npy differ
diff --git a/tests/testdata/testdata_path_BaseGeo.p b/tests/testdata/testdata_path_BaseGeo.p
new file mode 100644
index 000000000..1ad4b8793
Binary files /dev/null and b/tests/testdata/testdata_path_BaseGeo.p differ
diff --git a/tests/testdata/testdata_vs_mag2.p b/tests/testdata/testdata_vs_mag2.p
new file mode 100644
index 000000000..2ce9b352f
Binary files /dev/null and b/tests/testdata/testdata_vs_mag2.p differ
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index dd5a687d8..000000000
--- a/tox.ini
+++ /dev/null
@@ -1,13 +0,0 @@
-[tox]
-envlist = py37
-
-[testenv]
-# install pytest in the virtualenv where commands will be executed
-passenv = CODECOV_TOKEN
-deps = pytest-cov
-       codecov
-commands =
-    # NOTE: you can run any command line tool here - not just tests
-    py.test --junitxml=test-results/magpylib/results.xml --cov-report html --cov-report term:skip-covered --cov=magpylib 
-    codecov
-    
\ No newline at end of file