From 8ef64c1e519167c261c43ae5e5775e4c9299f332 Mon Sep 17 00:00:00 2001 From: Jen Bradley <55467578+janbridley@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:26:44 -0500 Subject: [PATCH] Refactor Shader interface and set up documentation (#9) * Update README.md with CI badge * Add docs * Update docs * Update shaders * Format shaders and docs * Update shaders * Clean up * Fix light_direction and base_style configuration * Update example to fit new api * Implement ABC * Update to new non-base shader * Continue adding docs * Add svgwrite to conf.py * Docs for main package * Update conf.py * Update doc code * Continue documentation * Format example.py * Move images to _static * Document most methods in the main package * Update RST files and README * Fix doctest config * Add logos * Update docs * Add logos in correct photo * Remove duplicate files * Update version to 0.1.0 --- 1AI0.mtl | 128 ++++++++++ README.md | 1 + README.rst | 169 +++++++++++++ doc/Makefile | 20 ++ doc/make.bat | 35 +++ doc/requirements.in | 4 + doc/requirements.txt | 62 +++++ .../CrumpledDevelopable-tri-compact.svg | 0 doc/source/_static/_svg3d-logo-light.svg | 52 ++++ doc/source/_static/_svg3d-logo.svg | 47 ++++ .../_static}/bunny-tri-compact.svg | 0 .../_static}/cube-wireframe.svg | 0 .../_static}/cycle-compact.svg | 0 doc/{svgs => source/_static}/dim.svg | 0 doc/{svgs => source/_static}/iso.svg | 0 .../_static}/oloid_64-tri-compact.svg | 0 doc/source/_static/svg3d-logo-text.svg | 52 ++++ doc/{svgs => source/_static}/teapot-tri.svg | 0 doc/{svgs => source/_static}/tri.svg | 0 doc/source/_static/truncated_cube.svg | 2 + doc/source/conf.py | 66 +++++ doc/source/development.rst | 81 +++++++ doc/source/index.rst | 29 +++ doc/source/installation.rst | 3 + doc/source/license.rst | 5 + doc/source/module-io.rst | 7 + doc/source/module-shaders.rst | 8 + doc/source/module-svg3d.rst | 8 + doc/source/module-view.rst | 9 + doc/source/quickstart.rst | 6 + doc/source/svg3d-logo-text.svg | 52 ++++ doc/source/svg3d-logo.svg | 47 ++++ doc/source/usage-example.rst | 5 + doc/svgs/icos_colored_rot.svg | 2 + doc/svgs/truncated_cube.svg | 2 - example.py | 24 +- pyproject.toml | 6 +- svg3d/io.py | 46 ++++ svg3d/shaders.py | 225 +++++++++++++++++- svg3d/svg3d.py | 165 +++++++++---- svg3d/view.py | 155 ++++++++++-- 41 files changed, 1429 insertions(+), 94 deletions(-) create mode 100644 1AI0.mtl create mode 100644 README.rst create mode 100644 doc/Makefile create mode 100644 doc/make.bat create mode 100644 doc/requirements.in create mode 100644 doc/requirements.txt rename doc/{svgs => source/_static}/CrumpledDevelopable-tri-compact.svg (100%) create mode 100644 doc/source/_static/_svg3d-logo-light.svg create mode 100644 doc/source/_static/_svg3d-logo.svg rename doc/{svgs => source/_static}/bunny-tri-compact.svg (100%) rename doc/{svgs => source/_static}/cube-wireframe.svg (100%) rename doc/{svgs => source/_static}/cycle-compact.svg (100%) rename doc/{svgs => source/_static}/dim.svg (100%) rename doc/{svgs => source/_static}/iso.svg (100%) rename doc/{svgs => source/_static}/oloid_64-tri-compact.svg (100%) create mode 100644 doc/source/_static/svg3d-logo-text.svg rename doc/{svgs => source/_static}/teapot-tri.svg (100%) rename doc/{svgs => source/_static}/tri.svg (100%) create mode 100644 doc/source/_static/truncated_cube.svg create mode 100644 doc/source/conf.py create mode 100644 doc/source/development.rst create mode 100644 doc/source/index.rst create mode 100644 doc/source/installation.rst create mode 100644 doc/source/license.rst create mode 100644 doc/source/module-io.rst create mode 100644 doc/source/module-shaders.rst create mode 100644 doc/source/module-svg3d.rst create mode 100644 doc/source/module-view.rst create mode 100644 doc/source/quickstart.rst create mode 100644 doc/source/svg3d-logo-text.svg create mode 100644 doc/source/svg3d-logo.svg create mode 100644 doc/source/usage-example.rst create mode 100644 doc/svgs/icos_colored_rot.svg delete mode 100644 doc/svgs/truncated_cube.svg create mode 100644 svg3d/io.py diff --git a/1AI0.mtl b/1AI0.mtl new file mode 100644 index 0000000..a34a6bb --- /dev/null +++ b/1AI0.mtl @@ -0,0 +1,128 @@ +newmtl 0x4259ff1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.259 0.349 1 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x1b9e771 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.106 0.62 0.467 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xff26181 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 1 0.149 0.094 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xffffff1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 1 1 1 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xffff3e1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 1 1 0.243 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xd95f021 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.851 0.373 0.008 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x7570b31 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.459 0.439 0.702 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xe7298a1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.906 0.161 0.541 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x66a61e1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.4 0.651 0.118 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xe6ab021 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.902 0.671 0.008 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xa6761d1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.651 0.463 0.114 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x6666661 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.4 0.4 0.4 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0xe41a1c1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.894 0.102 0.11 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x377eb81 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.216 0.494 0.722 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x4daf4a1 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.302 0.686 0.29 +Ks 0.25 0.25 0.25 +d 1 +newmtl 0x984ea31 +illum 2 +Ns 163 +Ni 0.001 +Ka 0 0 0 +Kd 0.596 0.306 0.639 +Ks 0.25 0.25 0.25 +d 1 diff --git a/README.md b/README.md index a68201d..65e3721 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Many thanks to the [Keenan 3D Model repository](https://www.cs.cmu.edu/~kmcrane/ [![GitHub Actions](https://github.com/janbridley/svg3d/actions/workflows/run-pytest.yaml/badge.svg)](https://github.com/janbridley/svg3d/actions) + ## Installation ```bash diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..bb83da4 --- /dev/null +++ b/README.rst @@ -0,0 +1,169 @@ +.. container:: row + + .. image:: _static/cube-wireframe.svg + :alt: Cube Wireframe + :width: 17% + + .. image:: _static/cycle-compact.svg + :alt: Alternation Cycle + :width: 17% + + .. image:: _static/CrumpledDevelopable-tri-compact.svg + :alt: Keenan CrumpledDevelopable + :width: 17% + + .. image:: _static/oloid_64-tri-compact.svg + :alt: Keenan Oloid + :width: 17% + + .. image:: _static/bunny-tri-compact.svg + :alt: Stanford Bunny + :width: 17% + + + +SVG3D was designed to bridge the gap between raytraced rendering engines like Blender and plotting tools like matplotlib and plotly. Common computer graphics techniques and models have been adapted to work within the counstraints of vector art, an approach that enables users to generate compact, scalable images with realistic shading. + +A reimagining of the excellent `original library `_ with the same name, this version has many new features, a more general interface, and a somewhat different scope. We aim to streamline the process of rendering scenes of geometries for scientific publications, although the libary is useful for a diverse array of applications. + +Many thanks to the `Keenan 3D Model repository `_ and the `Georgia Tech Large Models Archive `_ for the models rendered in the header image. + +.. image:: https://github.com/janbridley/svg3d/actions/workflows/run-pytest.yaml/badge.svg + :target: https://github.com/janbridley/svg3d/actions + +.. _installing: + +Installation +============ + +`svg3d` is not yet available via PyPI or Conda Forge. While this is the case, please +build from source to install the package. + +.. code-block:: bash + + # Clone the repository + git clone https://github.com/janbridley/svg3d.git + cd svg3d + + # Install to your python environment! + python -m pip install . + + +Quickstart Example +================== + +`svg3d` provides convenience `View` options for standard rendering perspectives - isometric, dimetric, and trimetric. Shapes can be easily created from coxeter objects, or from raw mesh data. + +.. code-block:: python + + from coxeter.families import ArchimedeanFamily + import svg3d + + style = { + "fill": "#00B2A6", + "fill_opacity": "0.85", + "stroke": "black", + "stroke_linejoin": "round", + "stroke_width": "0.005", + } + + truncated_cube = ArchimedeanFamily.get_shape("Truncated Cube") + + scene = [ + svg3d.Mesh.from_coxeter( + truncated_cube, style=style, shader=svg3d.shaders.diffuse_lighting + ) + ] + + # Convenience views: isometric, dimetric, and trimetric + iso = svg3d.View.isometric(scene, fov=1.0) + dim = svg3d.View.dimetric(scene, fov=1.0) + tri = svg3d.View.trimetric(scene, fov=1.0) + + for view, view_type in zip([iso, dim, tri], ["iso", "dim", "tri"]): + svg3d.Engine([view]).render(f"{view_type}.svg") + +.. list-table:: + :header-rows: 1 + + * - Isometric + - Dimetric + - Trimetric + * - .. image:: _static/iso.svg + - .. image:: _static/dim.svg + - .. image:: _static/tri.svg + +.. _usage: + +Usage Example +============= + +In addition to convenience methods, `svg3d` allows full control over the viewport, scene geometry, image style, and shaders. Methods are based on OpenGL standards and nomenclature where possible, and images can be created from any set of vertices and faces - even from ragged arrays! Simply pass an array of vertices and a list of arrays (one for vertex indices of each face, as below) to `svg3d.Mesh.from_vertices_and_faces` to render whatever geometry you like. Custom shader models can be implemented as a callable that takes a face index and a `svg3d.Mesh` object to shade. + +.. code-block:: python + + import numpy as np + import svg3d + + # Define the vertices and faces of a cube + vertices = 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.]] + ) + + faces = [ + [0, 2, 6, 4], + [0, 4, 5, 1], + [4, 6, 7, 5], + [0, 1, 3, 2], + [2, 3, 7, 6], + [1, 5, 7, 3] + ] + + # Set up our rendering style - transparent white gives a nice wireframe appearance + style = { + "fill": "#FFFFFF", + "fill_opacity": "0.75", + "stroke": "black", + "stroke_linejoin": "round", + "stroke_width": "0.005", + } + + empty_shader = lambda face_index, mesh: {} # Does nothing, but illustrates the shader API + + pos_object = [0.0, 0.0, 0.0] # "at" position + pos_camera = [40, 40, 120] # "eye" position + vec_up = [0.0, 1.0, 0.0] # "up" vector of camera. This is the default value. + + z_near, z_far = 1.0, 200.0 + aspect = 1.0 # Aspect ratio of the view cone + fov_y = 2.0 # Opening angle of the view cone. fov_x is equal to fov_y * aspect + + look_at = svg3d.get_lookat_matrix(pos_object, pos_camera, vec_up=vec_up) + projection = svg3d.get_projection_matrix( + z_near=z_near, z_far=z_far, fov_y=fov_y, aspect=aspect + ) + + # A "scene" is a list of Mesh objects, which can be easily generated from raw data + scene = [ + svg3d.Mesh.from_vertices_and_faces(vertices, faces, style=style, shader=empty_shader) + ] + + view = svg3d.View.from_look_at_and_projection( + look_at=look_at, + projection=projection, + scene=scene, + ) + + svg3d.Engine([view]).render("cube-wireframe.svg") + +Running the code above generates the following image: + +.. image:: _static/cube-wireframe.svg + diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +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 + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%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.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/doc/requirements.in b/doc/requirements.in new file mode 100644 index 0000000..bc74be6 --- /dev/null +++ b/doc/requirements.in @@ -0,0 +1,4 @@ +autodocsumm +furo +numpy +sphinx diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..758d46e --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,62 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in +alabaster==1.0.0 + # via sphinx +autodocsumm==0.2.14 + # via -r requirements.in +babel==2.16.0 + # via sphinx +beautifulsoup4==4.12.3 + # via furo +certifi==2024.8.30 + # via requests +charset-normalizer==3.4.0 + # via requests +docutils==0.21.2 + # via sphinx +furo==2024.8.6 + # via -r requirements.in +idna==3.10 + # via requests +imagesize==1.4.1 + # via sphinx +jinja2==3.1.4 + # via sphinx +markupsafe==3.0.2 + # via jinja2 +numpy==2.1.3 + # via -r requirements.in +packaging==24.2 + # via sphinx +pygments==2.18.0 + # via + # furo + # sphinx +requests==2.32.3 + # via sphinx +snowballstemmer==2.2.0 + # via sphinx +soupsieve==2.6 + # via beautifulsoup4 +sphinx==8.1.3 + # via + # -r requirements.in + # autodocsumm + # furo + # sphinx-basic-ng +sphinx-basic-ng==1.0.0b2 + # via furo +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx +urllib3==2.2.3 + # via requests diff --git a/doc/svgs/CrumpledDevelopable-tri-compact.svg b/doc/source/_static/CrumpledDevelopable-tri-compact.svg similarity index 100% rename from doc/svgs/CrumpledDevelopable-tri-compact.svg rename to doc/source/_static/CrumpledDevelopable-tri-compact.svg diff --git a/doc/source/_static/_svg3d-logo-light.svg b/doc/source/_static/_svg3d-logo-light.svg new file mode 100644 index 0000000..2d69ebe --- /dev/null +++ b/doc/source/_static/_svg3d-logo-light.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/source/_static/_svg3d-logo.svg b/doc/source/_static/_svg3d-logo.svg new file mode 100644 index 0000000..a159064 --- /dev/null +++ b/doc/source/_static/_svg3d-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/svgs/bunny-tri-compact.svg b/doc/source/_static/bunny-tri-compact.svg similarity index 100% rename from doc/svgs/bunny-tri-compact.svg rename to doc/source/_static/bunny-tri-compact.svg diff --git a/doc/svgs/cube-wireframe.svg b/doc/source/_static/cube-wireframe.svg similarity index 100% rename from doc/svgs/cube-wireframe.svg rename to doc/source/_static/cube-wireframe.svg diff --git a/doc/svgs/cycle-compact.svg b/doc/source/_static/cycle-compact.svg similarity index 100% rename from doc/svgs/cycle-compact.svg rename to doc/source/_static/cycle-compact.svg diff --git a/doc/svgs/dim.svg b/doc/source/_static/dim.svg similarity index 100% rename from doc/svgs/dim.svg rename to doc/source/_static/dim.svg diff --git a/doc/svgs/iso.svg b/doc/source/_static/iso.svg similarity index 100% rename from doc/svgs/iso.svg rename to doc/source/_static/iso.svg diff --git a/doc/svgs/oloid_64-tri-compact.svg b/doc/source/_static/oloid_64-tri-compact.svg similarity index 100% rename from doc/svgs/oloid_64-tri-compact.svg rename to doc/source/_static/oloid_64-tri-compact.svg diff --git a/doc/source/_static/svg3d-logo-text.svg b/doc/source/_static/svg3d-logo-text.svg new file mode 100644 index 0000000..b0f8c90 --- /dev/null +++ b/doc/source/_static/svg3d-logo-text.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/svgs/teapot-tri.svg b/doc/source/_static/teapot-tri.svg similarity index 100% rename from doc/svgs/teapot-tri.svg rename to doc/source/_static/teapot-tri.svg diff --git a/doc/svgs/tri.svg b/doc/source/_static/tri.svg similarity index 100% rename from doc/svgs/tri.svg rename to doc/source/_static/tri.svg diff --git a/doc/source/_static/truncated_cube.svg b/doc/source/_static/truncated_cube.svg new file mode 100644 index 0000000..b3dbd71 --- /dev/null +++ b/doc/source/_static/truncated_cube.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..bf7077f --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,66 @@ +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import sys + +sys.path.append(os.path.join("..", "..", "svg3d")) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "svg3d" +copyright = "2024, Jenna Bradley" +author = "Jenna Bradley" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.mathjax", +] + +templates_path = ["_templates"] +exclude_patterns = [] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "matplotlib": ("https://matplotlib.org", None), + "coxeter": ("https://coxeter.readthedocs.io/en/stable", None), + "svgwrite": ("https://svgwrite.readthedocs.io/en/latest/", None), +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "furo" +html_static_path = ["_static"] +html_theme_options = { + "sidebar_hide_name": True, + "light_logo": "svg3d-logo-text.svg", + "dark_logo": "svg3d-logo-light.svg", + "top_of_page_buttons": ["view", "edit"], + "navigation_with_keys": True, + "dark_css_variables": { + "color-brand-primary": "#AFA8B9", + "color-brand-content": "#AFA8B9", + }, + "light_css_variables": { + "color-brand-primary": "#4A4453", + "color-brand-content": "#4A4453", + }, +} +html_favicon = "svg3d-logo.svg" + +autodoc_default_options = { + "members": True, + "private-members": False, + "show-inheritance": True, +} +autodoc_typehints = "description" diff --git a/doc/source/development.rst b/doc/source/development.rst new file mode 100644 index 0000000..b563ec4 --- /dev/null +++ b/doc/source/development.rst @@ -0,0 +1,81 @@ +.. _development: + +================= +Development Guide +================= + + +All contributions to **svg3d** are welcome! +Developers are invited to contribute to the framework by pull request to the package repository on `GitHub`_, and all users are welcome to provide contributions in the form of **user feedback** and **bug reports**. +We recommend discussing new features in form of a proposal on the issue tracker for the appropriate project prior to development. + +.. _github: https://github.com/janbridley/svg3d + +General Guidelines +================== + +All code contributed to **svg3d** must adhere to the following guidelines: + + * Use a two branch model of development: + + - Most new features and bug fixes should be developed in branches based on ``main``. + - API incompatible changes and those that significantly change existing functionality should be based on ``breaking`` + * Hard dependencies (those that end users must install to use **svg3d**) are discouraged, and should be avoided where possible. + * All code should adhere to the source code conventions and satisfy the documentation and testing requirements discussed below. + + +Style Guidelines +---------------- + +The **svg3d** package adheres to a reasonably strict set of style guidelines. +All code in **svg3d** should be formatted using `ruff`_ via pre-commit. This provides an easy workflow to enforce a number of style and syntax rules that have been configured for the project. + +.. tip:: + + `pre-commit`_ has been configured to run a number of linting and formatting tools. It is recommended to set up pre-commit to run automatically: + + .. code-block:: bash + + python -m pip install pre-commit + pre-commit install # Set up git hook scripts + + Alternatively, the tools can be run manually with the following command: + + .. code-block:: bash + + git add .; pre-commit run + +.. _ruff: https://docs.astral.sh/ruff/ +.. _pre-commit: https://pre-commit.com/ + + +Documentation +------------- + +API documentation should be written as part of the docstrings of the package in the `Google style `__. + +Docstrings are automatically validated using `pydocstyle `_ whenever the ruff pre-commit hooks are run. +The `official documentation `_ is generated from the docstrings using `Sphinx `_. + +In addition to API documentation, inline comments are strongly encouraged. +Code should be written as transparently as possible, so the primary goal of documentation should be explaining the algorithms or mathematical concepts underlying the code. + +Building Documentation +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: bash + + cd doc + make clean + make html + open build/html/index.html + + +Unit Tests +---------- + +All code should include a set of tests which test for correct behavior. +All tests should be placed in the ``tests`` folder at the root of the project. +In general, most parts of svg3d primarily require `unit tests `_, but where appropriate `integration tests `_ are also welcome. Core functions should be tested against the sample CIF files included in ``tests/sample_data``. +Tests in **svg3d** use the `pytest `__ testing framework. +To run the tests, simply execute ``pytest`` at the root of the repository. diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..1051d08 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,29 @@ +.. include:: ../../README.rst + :end-before: Usage Example + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + + installation + quickstart + usage-example + + +.. toctree:: + :maxdepth: 2 + :caption: API + + module-svg3d + module-shaders + module-view + + +.. toctree:: + :maxdepth: 1 + :caption: Reference + + genindex + modindex + development + license diff --git a/doc/source/installation.rst b/doc/source/installation.rst new file mode 100644 index 0000000..575563e --- /dev/null +++ b/doc/source/installation.rst @@ -0,0 +1,3 @@ +.. include:: ../../README.rst + :start-after: .. _installing: + :end-before: Quickstart Example diff --git a/doc/source/license.rst b/doc/source/license.rst new file mode 100644 index 0000000..6bead3b --- /dev/null +++ b/doc/source/license.rst @@ -0,0 +1,5 @@ +License +======= + +.. literalinclude:: ../../LICENSE + :language: none diff --git a/doc/source/module-io.rst b/doc/source/module-io.rst new file mode 100644 index 0000000..71f278d --- /dev/null +++ b/doc/source/module-io.rst @@ -0,0 +1,7 @@ +svg3d.io +======== + +.. automodule:: svg3d.io + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/source/module-shaders.rst b/doc/source/module-shaders.rst new file mode 100644 index 0000000..557c740 --- /dev/null +++ b/doc/source/module-shaders.rst @@ -0,0 +1,8 @@ +svg3d.shaders +============= + +.. automodule:: svg3d.shaders + :members: + :undoc-members: + :special-members: __call__, __init__ + :show-inheritance: diff --git a/doc/source/module-svg3d.rst b/doc/source/module-svg3d.rst new file mode 100644 index 0000000..0642c53 --- /dev/null +++ b/doc/source/module-svg3d.rst @@ -0,0 +1,8 @@ +svg3d +===== + +.. automodule:: svg3d.svg3d + :members: + :special-members: __init__ + :undoc-members: + :show-inheritance: diff --git a/doc/source/module-view.rst b/doc/source/module-view.rst new file mode 100644 index 0000000..8de24b4 --- /dev/null +++ b/doc/source/module-view.rst @@ -0,0 +1,9 @@ +svg3d.view +========== + +.. rubric:: Overview + +.. automodule:: svg3d.view + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst new file mode 100644 index 0000000..af06298 --- /dev/null +++ b/doc/source/quickstart.rst @@ -0,0 +1,6 @@ +Quickstart Example +================== + +.. include:: ../../README.rst + :start-after: Quickstart Example + :end-before: Usage Example diff --git a/doc/source/svg3d-logo-text.svg b/doc/source/svg3d-logo-text.svg new file mode 100644 index 0000000..b0f8c90 --- /dev/null +++ b/doc/source/svg3d-logo-text.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/source/svg3d-logo.svg b/doc/source/svg3d-logo.svg new file mode 100644 index 0000000..a159064 --- /dev/null +++ b/doc/source/svg3d-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/source/usage-example.rst b/doc/source/usage-example.rst new file mode 100644 index 0000000..288a756 --- /dev/null +++ b/doc/source/usage-example.rst @@ -0,0 +1,5 @@ +Usage Example +================== + +.. include:: ../../README.rst + :start-after: Usage Example diff --git a/doc/svgs/icos_colored_rot.svg b/doc/svgs/icos_colored_rot.svg new file mode 100644 index 0000000..836e809 --- /dev/null +++ b/doc/svgs/icos_colored_rot.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/doc/svgs/truncated_cube.svg b/doc/svgs/truncated_cube.svg deleted file mode 100644 index 80168fb..0000000 --- a/doc/svgs/truncated_cube.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/example.py b/example.py index e2239da..ed5ccf5 100644 --- a/example.py +++ b/example.py @@ -3,6 +3,15 @@ import svg3d from svg3d import get_lookat_matrix, get_projection_matrix +# The style of our SVG images can be stored in a dictionary object +style = { + "fill": "#71618D", + "fill_opacity": "0.85", + "stroke": "black", + "stroke_linejoin": "round", + "stroke_width": "0.005", +} + def generate_svg(filename, poly): pos_object = [0.0, 0.0, 0.0] # "at" position @@ -19,11 +28,8 @@ def generate_svg(filename, poly): ) # A "scene" is a list of Mesh objects, which can be easily generated from Coxeter! - scene = [ - svg3d.Mesh.from_coxeter( - poly, style=style, shader=svg3d.shaders.diffuse_lighting - ) - ] + shader = svg3d.shaders.DiffuseShader.from_style_dict(style) + scene = [svg3d.Mesh.from_coxeter(poly, style=style, shader=shader)] view = svg3d.View.from_look_at_and_projection( look_at=look_at, @@ -34,13 +40,5 @@ def generate_svg(filename, poly): svg3d.Engine([view]).render(filename) -style = { - "fill": "#71618D", - "fill_opacity": "0.85", - "stroke": "black", - "stroke_linejoin": "round", - "stroke_width": "0.005", -} - truncated_cube = ArchimedeanFamily.get_shape("Truncated Cube") generate_svg(filename="doc/svgs/truncated_cube.svg", poly=truncated_cube) diff --git a/pyproject.toml b/pyproject.toml index efccc61..221ec67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "svg3d" -version = "0.0.2" +version = "0.1.0" requires-python = ">=3.6" description = "Minimal library for rendering polyhedra as SVG wireframes." readme = "README.md" @@ -52,11 +52,11 @@ indent-style = "space" [tool.pytest.ini_options] # Additional command line options for pytest -addopts = "--doctest-modules -p coxeter.__doctest_fixtures" +addopts = "--doctest-modules --doctest-continue-on-failure --doctest-glob='*.rst'" doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" # Add percentage progress bar to the pytest console output console_output_style = "progress" # Specify the tests folder to speed up collection. -testpaths = ["tests"] +testpaths = ["tests", "svg3d"] # Disable hypothesis health checks - these are frustrating at best # hypothesis-suppress_health_check = ["filter_too_much"] diff --git a/svg3d/io.py b/svg3d/io.py new file mode 100644 index 0000000..28a2d39 --- /dev/null +++ b/svg3d/io.py @@ -0,0 +1,46 @@ +class OBJ: + @classmethod + def _parse_line(cls, line): + """Parse a line into 'words', each containing one or more pieces of data.""" + + return [word.split("/") for word in line.split()] + # return line.split() + + @classmethod + def _parse_mtl_into_colors(cls, filename: str, keys: tuple[str] = ("Kd",)): + """Extract diffuse color values (Kd) from an obj .mtl file. + + https://paulbourke.net/dataformats/mtl/ + """ + with open(filename) as f: + _materials = {} + key = None + + for line in f: + words = cls._parse_line(line) + if not words: + continue + + if words[0] == ["newmtl"]: + key = words[1] + print(key) + _materials[key] = {} + continue + elif words[0][0] in keys: + _materials[key][words[0]] = words[1:] + + print(_materials) + + # def __init__(self, filename: str): + # vertices, faces = [], [] + + # with open(filename) as f: + # for line in f: + # words = self._parse_line(line) + + # if not words: continue + + +if __name__ == "__main__": + materials = OBJ._parse_mtl_into_colors(filename="1AI0.mtl") + print("MATS", materials) diff --git a/svg3d/shaders.py b/svg3d/shaders.py index d405933..9d88eba 100644 --- a/svg3d/shaders.py +++ b/svg3d/shaders.py @@ -1,23 +1,63 @@ +from abc import ABC, abstractmethod + import numpy as np -DEFAULT_LIGHT = np.array([2, 2, 1]) / 2 +DEFAULT_LIGHT = np.array([1, 1, 0.5], dtype=float) def hex2rgb(hexc): + """ + Convert a hexadecimal color string to an RGB array normalized to [0, 1]. + + Parameters + ---------- + hexc : str + A hexadecimal color string, with or without a leading `#`. + + Returns + ------- + :math:`(3,)` :class:`numpy.ndarray`: + A NumPy array containing RGB values normalized to the range [0, 1]. + + Examples + -------- + >>> hex2rgb("#FFFFFF") + array([1., 1., 1.]) + >>> hex2rgb("000000") + array([0., 0., 0.]) + """ hexc = hexc.lstrip("#") return np.array([int(hexc[i : i + 2], 16) for i in (0, 2, 4)]) / 255.0 def rgb2hex(rgb): + """ + Convert an RGB color array to a hexadecimal color string. + + Parameters + ---------- + rgb : :math:`(3,)` :class:`numpy.ndarray`: The RGB values to convert. + + Returns + ------- + str : A hexadecimal color string in uppercase format, prefixed with `#`. + + Examples + -------- + >>> rgb2hex(np.array([1.0, 1.0, 1.0])) + '#FFFFFF' + >>> rgb2hex(np.array([0.0, 0.0, 0.0])) + '#000000' + """ rgb = (rgb * 255).astype(int) return "#{:02x}{:02x}{:02x}".format(*rgb).upper() -def _apply_shading(base_color, shading, factor=0.5): +def _apply_shading(base_color, shading, absorbance=0.5): # `shading` is a value between -1 and 1 # factor controls how much lighter/darker we go from the base color base_rgb = hex2rgb(base_color) - shaded_color = base_rgb + factor * shading * (np.ones(3) - base_rgb) + shaded_color = base_rgb + absorbance * shading * (np.ones(3) - base_rgb) shaded_color = np.clip(shaded_color, 0, 1) # Ensure RGB values are within [0, 1] return rgb2hex(shaded_color) @@ -26,12 +66,189 @@ def _apply_shading(base_color, shading, factor=0.5): def diffuse_lighting( face_index, mesh, light_direction=None, base_style=None, base_color="#71618D" ): + """Apply Lambertian (dot product diffuse) shading to a face in an \ + :obj:`~.Mesh`. + + This is a convenience function for backwards compatibility. The full-featured + :obj:`~.Shader` class should be used in most instances. + """ + base_style = base_style if base_style is not None else {} light_direction = light_direction if light_direction is not None else DEFAULT_LIGHT normal = mesh.normals[face_index] / np.linalg.norm(mesh.normals[face_index]) shading = np.dot(normal, light_direction) - new_color = _apply_shading(base_color, shading, factor=0.6) + new_color = _apply_shading(base_color, shading, absorbance=0.6) return base_style | {"fill": new_color} + + + +class Shader(ABC): + """ + Abstract base class for shaders. + """ + + def __init__(self, base_color="#71618D", base_style=None): + """Initialize the shader. + + Parameters + ---------- + base_color : str, optional + A hexadecimal-formatted color string for the mesh. Default is "#71618D". + base_style: dict | None, optional + The style attribute dict for the :obj:`~.Shader`. + """ + + self._base_color = base_color + self._base_style = base_style + + @abstractmethod + def __call__(self, face_index, mesh, absorbance=0.6): + """Compute the shaded style for a face in a mesh. + + Abstract method to be implemented in subclasses. + """ + return {} + + @property + def base_style(self): + """dict: Get or set the style attribute dict for the object.""" + return self._base_style + + @base_style.setter + def base_style(self, base_style: dict): + self._base_style = base_style + +class DiffuseShader(Shader): + """ + Shade Mesh objects with per-face, Lambertian (dot product diffuse) lighting. + """ + + def __init__(self, base_color="#71618D", light_direction=DEFAULT_LIGHT, base_style=None): + """Initialize the diffuse shader. + + Parameters + ---------- + base_color : str, optional + A hexadecimal-formatted color string for the mesh. Default is "#71618D". + light_direction : iterable of float, optional + A 3-element array specifying the direction of the light source. + Default is (1.0, 1.0, 0.5). + base_style : dict | None, optional + The style dict for the :obj:`~.Shader`. + """ + super().__init__(base_color=base_color, base_style=base_style) + self._diffuse_light_direction = np.asarray(light_direction) + + @classmethod + def from_style_dict(cls, style: dict, light_direction = DEFAULT_LIGHT): + """Create a :obj:`~.Shader` instance with a style dictionary. + + Parameters + ---------- + style : dict + The style dict for the :obj:`~.Shader` + light_direction : array or list of float, optional. + A 3-element iterable specifying the diffuse light direction. Default \ + value: (1.0, 1.0, 0.5) + """ + new = cls(base_color=style["fill"], light_direction=light_direction) + new.base_style = style + return new + + + @classmethod + def from_color(cls, base_color): + """Create a :obj:`~.Shader` instance with a specified base color. + + Parameters + ---------- + base_color : str + The base color as a hexadecimal string (e.g., `#FFFFFF`). + """ + return cls(base_color=base_color) + + @classmethod + def from_color_and_direction(cls, base_color, light_direction): + """Create a :obj:`~.Shader` instance with a specified base color \ + and light direction. + + Parameters + ---------- + base_color : str + The base color as a hexadecimal string (e.g., `#FFFFFF`). + light_direction : array or list of float + A 3-element iterable specifying the diffuse light direction. + """ + return cls(base_color=base_color, light_direction=light_direction) + + def __call__(self, face_index, mesh, absorbance=0.6): + """Compute the shaded style for a face in a mesh. + + Parameters + ---------- + face_index : int + Index of the face in the mesh. + mesh : Mesh + An svg3d mesh object. + absorbance : float, optional + The "absorbance" of the mesh surface. Should fall in the range [0.0, 1.0), \ + with larger values equating to darker shading. Default is 0.6. + + Returns + ------- + dict + A dictionary containing the SVG style attributes for the shaded face. + """ + base_style = self.base_style if self.base_style is not None else {} + + normal = mesh.normals[face_index] / np.linalg.norm(mesh.normals[face_index]) + shading = np.dot(normal, self.diffuse_light_direction) + + new_color = self._apply_shading(self.base_color, shading, absorbance=absorbance) + + return {**base_style, "fill": new_color} + + def _apply_shading(self, base_color, shading, absorbance=0.5): + """Apply shading model to an input color.""" + base_rgb = hex2rgb(base_color) + shaded_color = base_rgb + absorbance * shading * (np.ones(3) - base_rgb) + shaded_color = np.clip(shaded_color, 0, 1) + return rgb2hex(shaded_color) + + @property + def diffuse_light_direction(self): + """ + np.ndarray: A 3-element array representing the direction of the light source. + """ + return self._diffuse_light_direction + + @diffuse_light_direction.setter + def diffuse_light_direction(self, light_direction): + """ + Set the direction of the diffuse light source. + + Parameters + ---------- + light_direction : array or list of float + A 3-element iterable specifying the diffuse light direction. + + Raises + ------ + AssertionError + If light_direction is not an iterable of length three. + """ + msg = "Light direction should be an iterable with length three." + assert hasattr(light_direction, "__len__") and len(light_direction) == 3, msg + self._diffuse_light_direction = np.asarray(light_direction) + + @property + def base_color(self): + """dict: Get or set the base color for the mesh from a hexadecimal string.""" + return self._base_color + + @base_color.setter + def base_color(self, base_color): + self._base_color = base_color diff --git a/svg3d/svg3d.py b/svg3d/svg3d.py index f66840d..bc30745 100644 --- a/svg3d/svg3d.py +++ b/svg3d/svg3d.py @@ -2,7 +2,12 @@ # Copyright (c) 2019 Philip Rideout. Modified 2024 by Jenna Bradley. # Distributed under the MIT License, see bottom of file. +"""Three-dimensional vector rendering software in Python. +This primary package contains object primitives (:obj:`~.Mesh`) and the rendering engine +:obj:`~.Engine` itself. + +""" import warnings from typing import TYPE_CHECKING, Callable @@ -13,6 +18,15 @@ if TYPE_CHECKING: import coxeter +EXAMPLE_COLOR = "#71618D" +EXAMPLE_STYLE = { + "fill": EXAMPLE_COLOR, + "fill_opacity": "0.85", + "stroke": "black", + "stroke_linejoin": "round", + "stroke_width": "0.005", +} # Sample style dictionary for use in examples. + def _pad_arrays(arrays): # Find the length of the longest array @@ -26,7 +40,7 @@ def _pad_arrays(arrays): return np.array(padded_array) -class Mesh: +class Mesh: # TODO: rename to PolygonMesh, create Object? base class, and add Sphere def __init__( self, faces: list[np.ndarray], @@ -42,15 +56,18 @@ def __init__( @property def faces(self): + """np.ndarray: Get or set the faces of the :obj:`~.Mesh`""" return self._faces @faces.setter - def faces(self, faces): + def faces(self, faces: list[np.ndarray]): self._faces = faces self._compute_normals() @property def shader(self): + """:py:obj:`~typing.Callable`: Get or set the :obj:`~.Shader` for the \ + :obj:`~.Mesh`""" return self._shader @shader.setter @@ -59,6 +76,7 @@ def shader(self, shader): @property def style(self): + """dict: Get or set the style dictionary for the mesh.""" return self._style @style.setter @@ -75,6 +93,7 @@ def circle_radius(self, circle_radius): @property def normals(self): + """np.ndarray: Get the normals for the faces of the :obj:`~.Mesh`.""" return self._normals def _compute_normals(self): @@ -93,9 +112,11 @@ def _compute_normals(self): def from_coxeter( cls, poly: "coxeter.shapes.ConvexPolyhedron", - shader=None, - style=None, - ): # noqa: F821 + shader: Callable[[int, float], dict] | None = None, + style: dict | None =None, + ): + """Create a :obj:`~.Mesh` object from a coxeter + :class:`~coxeter.shapes.ConvexPolyhedron`.""" return cls( faces=[poly.vertices[face] for face in poly.faces], shader=shader, @@ -107,23 +128,84 @@ def from_vertices_and_faces( cls, vertices: np.ndarray[float], faces: list[np.ndarray[int]], - shader=None, - style=None, - ): # noqa: F821 + shader: Callable[[int, float], dict] | None = None, + style: dict | None =None, + ): return cls( faces=[vertices[face] for face in faces], shader=shader, style=style, ) + @classmethod + def example_mesh(cls): + """Generate a mesh from a cube with integer vertices. + + This is an internal method used for tests and examples, and should probably not + be instantiated by users. + + :meta private: + """ + # TODO: define default style dict, vertices, and faces + from .shaders import DiffuseShader + + # Generate the vertices and faces of a cube + partial_vertices = np.tile([-0.5, 0.5], (3, 1)) + vertices = np.array(np.meshgrid(*partial_vertices)).T.reshape(-1, 3) + + faces = [ + [0, 2, 6, 4], + [0, 4, 5, 1], + [4, 6, 7, 5], + [0, 1, 3, 2], + [2, 3, 7, 6], + [1, 5, 7, 3] + ] + + return cls( + faces=[vertices[face] for face in faces], + shader=DiffuseShader(base_style=EXAMPLE_STYLE), + style=EXAMPLE_STYLE + ) + class Engine: - def __init__(self, views, precision=7): + def __init__(self, views, precision:int=10): + """The engine used to render a scene into an image. + + + Example + ------- + > import svg3d + > scene = [svg3d.Mesh.example_mesh()] + > view = svg3d.View.isometric(scene) + > svg3d.Engine([view]).render("example.svg") + Wrote file "example.svg" + + + Parameters + ---------- + views: list[View] + List of :obj:`~.View` objects to render. Each is rendered into the same + image, allowing for composite graphics from multiple viewpoints. For + simplicity, a single :obj:`~.View` object is often best. + precision: int + Number of decimal places of precision for numeric quantities in the mesh. + Smaller values will reduce file sizes but may result in minor + inconsistencies in very small geometries. Default value: 10 + """ self._views = views self._precision = precision @property def views(self): + """list[:obj:`~.View`]: Get or set the list of views to render.""" + if len(self._views) < 1: + warnings.warn( + "No views available! Rendered image will be blank.", + RuntimeWarning, + stacklevel=2, + ) return self._views @views.setter @@ -132,9 +214,36 @@ def views(self, views): @property def precision(self): + """int: Get or set the rounding precision for vertices of rendered polygons.""" + return self._precision + + @precision.setter + def precision(self, precision): return self._precision def render(self, filename, size=(512, 512), viewbox="-0.5 -0.5 1.0 1.0", **extra): + """ + Render the current view or views to a file. + + Parameters + ---------- + filename : str + The name of the file to save the render to. Should be postfixed with `.svg` + size : tuple of int, optional + Size of the render in pixels. Default is (512, 512). + viewbox : str, optional + :class:`~svgwrite.mixins.viewBox` attribute for the SVG. Default is \ + "-0.5 -0.5 1.0 1.0". + **extra + Additional keyword arguments to be passed into :py:mod:`svgwrite`. + + Raises + ------ + RuntimeWarning + If all faces of a mesh are pruned due to an incorrect projection matrix. + RuntimeWarning + If :meth:`~.render` is called without any Views to render. + """ drawing = svgwrite.Drawing(filename, size, viewBox=viewbox, **extra) self._draw(drawing) drawing.save() @@ -228,44 +337,6 @@ def _sort_back_to_front(self, faces): return np.argsort(z_centroids) -_directional_light = np.array([2, 2, 1]) / 2 - - -def _hex2rgb(hexc): - hexc = hexc.lstrip("#") - return np.array([int(hexc[i : i + 2], 16) for i in (0, 2, 4)]) / 255.0 - - -def _rgb2hex(rgb): - rgb = (rgb * 255).astype(int) - return "#{:02x}{:02x}{:02x}".format(*rgb).upper() - - -def _apply_shading(base_color, shading, factor=0.5): - # `shading` is a value between -1 and 1 - # factor controls how much lighter/darker we go from the base color - base_rgb = _hex2rgb(base_color) - shaded_color = base_rgb + factor * shading * (np.ones(3) - base_rgb) - - shaded_color = np.clip(shaded_color, 0, 1) # Ensure RGB values are within [0, 1] - return _rgb2hex(shaded_color) - - -BASE_COLOR = "#71618D" -base_style = {} - - -def shader(face_index, mesh, base_color="#71618D"): - mesh = mesh.faces - - # TODO - normal = mesh.normals[face_index] / np.linalg.norm(mesh.normals[face_index]) - shading = np.dot(normal, _directional_light) - - new_color = _apply_shading(base_color, shading, factor=0.6) - - return base_style | {"fill": new_color} - # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/svg3d/view.py b/svg3d/view.py index 0926357..9170111 100644 --- a/svg3d/view.py +++ b/svg3d/view.py @@ -1,3 +1,6 @@ +"""Define OpenGL-style views and viewports for scene rendering. +""" + import math from typing import NamedTuple @@ -17,23 +20,24 @@ def get_lookat_matrix( world such that the z-axis of the camera is the mathematical z axis. - Args: - pos_object (np.ndarray): - (N,3) position of the object we are looking at. "at" in openGL vernacular. - pos_camera (np.ndarray): - (N,3) position of the camera. "eye" in openGL vernacular. - vec_up (np.ndarray | tuple, optional): - (N,3) vector describing the height of the camera. "up" in openGL vernacular. - Default value: (0,1,0) + Parameters + ---------- + pos_object : :math:`(3,)` :class:`numpy.ndarray` + Position of the object we are looking at. "at" in openGL vernacular. + pos_camera : :math:`(3,)` :class:`numpy.ndarray` + Position of the camera. "eye" in openGL vernacular. + vec_up : :math:`(3,)` :class:`numpy.ndarray`: | tuple, optional + Vector describing the height of the camera. "up" in openGL vernacular. + Default value: (0.0, 1.0, 0.0) + - .. seealso: + .. seealso:: Calculating a Lookat Matrix: https://stackoverflow.com/questions/349050/calculating-a-lookat-matrix/6802424#6802424 - .. seealso: + .. seealso:: Understanding Lookat Matrices: https://medium.com/@carmencincotti/lets-look-at-magic-lookat-matrices-c77e53ebdf78 - """ # First, shift the world such that the camera is at the origin m_camera_translate = np.eye(4) @@ -62,25 +66,33 @@ def get_lookat_matrix( def get_projection_matrix( z_near: float, z_far: float, fov_y: float, aspect: float = 1.0 ): - """Get a projection matrix from frustum parameters. + """Get a projection matrix from parameters of the provided view frustum. z_near and z_far are the distances to the tip and base of the frustum, respectively. fov_y describes the opening angle of the base, and aspect describes the relationship - between the y opening angle and the x. + between the y opening angle and the x. Objects that lie outside the view frustum are + culled and wil not be rendered into the scene. + + .. # TODO: include image of frustum view + + Parameters + ---------- + z_near : float + Distance to the near clipping plane. Must be greater than zero. + z_far : float + Distance to the far clipping plane. Must be greater than z_near. + fov_y : float + Field of view angle along the y direction, in degrees. + aspect : float, optional + Ratio of field of view angle in the y direction to field of view angle in x. + Default value: 1.0 - Args: - z_near (float): Distance to the near clipping plane. Must be greater than zero. - z_far (float): Distance to the far clipping plane. Must be greater than z_near. - fov_y (float): Field of view angle along the y direction, in degrees. - aspect (float, optional): - Ratio of field of view angle in the y direction to field of view angle in x. - Default value: 1.0 - .. seealso: + .. seealso:: OpenGL Reference: https://registry.khronos.org/OpenGL-Refpages/gl2.1/xhtml/gluPerspective.xml - .. seealso: + .. seealso:: Understanding Projection Matrices: http://www.songho.ca/opengl/gl_projectionmatrix.html @@ -97,17 +109,29 @@ def get_projection_matrix( class Viewport(NamedTuple): + """A :obj:`~.Viewport` controls the visible area in a rendered SVG. + + This is a convience wrapper around the svgwrite :obj:`~svgwrite.mixins.Viewbox` + classes with a simplified interface. + """ + minx: float = -0.5 + """Left border of the viewport.""" miny: float = -0.5 + """Right border of the viewport.""" width: float = 1.0 + """Width of the viewport.""" height: float = 1.0 + """Height of the viewport.""" @classmethod def from_aspect(cls, aspect_ratio: float): + """Create a :obj:`~.Viewport` with the given aspect ratio.""" return cls(-aspect_ratio / 2.0, -0.5, aspect_ratio, 1.0) @classmethod def from_string(cls, string_to_parse: str): + """Create a :obj:`~.Viewport` from a space-delimited string of floats.""" args = [float(f) for f in string_to_parse.split()] return cls(*args) @@ -126,16 +150,21 @@ def __init__( self._viewport = viewport if viewport is not None else Viewport() DEFAULT_OBJECT_POSITION = np.zeros(3) + """Classmethods for this object center their view on the origin by default.""" ISOMETRIC_VIEW_MATRIX = [ [np.sqrt(3), -1, np.sqrt(2), 0], [0, 2, np.sqrt(2), 0], [-np.sqrt(3), -1, np.sqrt(2), 0], [0, 0, -100 * np.sqrt(6), np.sqrt(6)], - ] / np.sqrt(6) + ] / np.sqrt(6) # TODO: no-undoc-members, don't want to expose this @property def look_at(self): + """:math:`(4,4)` :class:`numpy.ndarray`: The openGL-style lookAt matrix. + + TODO: add links to openGL docs, explain transpose if required. + """ return self._look_at @look_at.setter @@ -144,6 +173,10 @@ def look_at(self, look_at: np.ndarray): @property def projection(self): + """:math:`(4,4)` :class:`numpy.ndarray`: The openGL-style projection matrix. + + TODO: add links to openGL docs, explain transpose if required. + """ return self._projection @projection.setter @@ -152,6 +185,7 @@ def projection(self, projection: np.ndarray): @property def scene(self): + """Iterable[Mesh] : Get or set the list of :obj:`~.Mesh` objects to render.""" return self._scene @scene.setter @@ -160,10 +194,11 @@ def scene(self, scene: tuple[Mesh] | list[Mesh]): @property def viewport(self): + """Viewport: Get or set the system's :obj:`~.Viewport`.""" return self._viewport @viewport.setter - def viewport(self, viewport): + def viewport(self, viewport: Viewport): self._viewport = viewport @classmethod @@ -173,7 +208,13 @@ def from_look_at_and_projection( projection: np.ndarray, scene: tuple[Mesh], ): - assert look_at.shape == (4, 4) and projection.shape == (4, 4) + """Create a new :obj:`~.View` from a lookAt and projection matrix. + + + TODO: Describe how these are composed, give matrix equations + """ + msg = "Both look_at and projection must have size (4,4)." + assert look_at.shape == (4, 4) and projection.shape == (4, 4), msg return cls( look_at, projection, @@ -182,6 +223,25 @@ def from_look_at_and_projection( @classmethod def isometric(cls, scene, fov: float = 1.0, distance: float = 100.0): + """Create a :obj:`~.View` based on an isometric projection. + + In an isometric projection, the scale along each coordinate axis is identical. + This is a parallel projection method, meaning that objects remain the same size + regardless of their position from the camera. This is useful in diagrams and + technical renderings but may be undesirable for realistic scenes. + + # TODO: Give example image or diagram showing an isometric projection + + Parameters + ---------- + scene : list[Mesh] + An iterable of mesh objects to view. + fov: float + Field of view, in degrees. Should be in the open range (0.0, 180.0). Default + value: 1.0 + distance: float + Distance of the viewer from the origin. Default value: 100.0 + """ # Equivalent to a 45 degree rotation about the X axis and an atan(1/sqrt(2)) # degree rotation about the z axis isometric_view = cls.ISOMETRIC_VIEW_MATRIX @@ -195,6 +255,28 @@ def isometric(cls, scene, fov: float = 1.0, distance: float = 100.0): @classmethod def dimetric(cls, scene, fov: float = 1.0, distance: float = 100.0): + """Create a :obj:`~.View` based on a dimetric projection. + + In a dimetric projection, the scale along two out of three axes is identical. + This strikes a balance between the simplicity and interpretability of isometric + projections and the improved sense of realism afforded by trimetric projections. + + This is a parallel projection method, meaning that objects remain the same size + regardless of their position from the camera. This is useful in diagrams and + technical renderings but may be undesirable for realistic scenes. + + # TODO: Give example image or diagram showing an dimetric projection + + Parameters + ---------- + scene : list[Mesh] + An iterable of mesh objects to view. + fov: float + Field of view, in degrees. Should be in the open range (0.0, 180.0). Default + value: 1.0 + distance: float + Distance of the viewer from the origin. Default value: 100.0 + """ # TODO: reimplement as https://faculty.sites.iastate.edu/jia/files/inline-files/projection-classify.pdf camera_position = np.array([8, 8, 21]) / math.sqrt(569) * distance return cls( @@ -207,6 +289,29 @@ def dimetric(cls, scene, fov: float = 1.0, distance: float = 100.0): @classmethod def trimetric(cls, scene, fov: float = 1.0, distance: float = 100.0): + """Create a :obj:`~.View` based on a trimetric projection. + + In a trimetric projection, each axis is scaled independently. This results in a + more "natural" scene than isometric and trimetric views, as the foreshortening + of each axis provides a sense of depth to the scene. + + + This is a parallel projection method, meaning that objects remain the same size + regardless of their position from the camera. This is useful in diagrams and + technical renderings but may be undesirable for realistic scenes. + + # TODO: Give example image or diagram showing a trimetric projection + + Parameters + ---------- + scene : list[Mesh] + An iterable of mesh objects to view. + fov: float + Field of view, in degrees. Should be in the open range (0.0, 180.0). Default + value: 1.0 + distance: float + Distance of the viewer from the origin. Default value: 100.0 + """ camera_position = np.array([1 / 7, 1 / 14, 3 / 14]) * math.sqrt(14) * distance return cls( look_at=get_lookat_matrix(