pax_global_header 0000666 0000000 0000000 00000000064 14566754545 0014537 g ustar 00root root 0000000 0000000 52 comment=e7f7d33b88210fbbbb76d3a6c256bb0de641f3e1
isosurfaces-0.1.2/ 0000775 0000000 0000000 00000000000 14566754545 0014065 5 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/.github/ 0000775 0000000 0000000 00000000000 14566754545 0015425 5 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/.github/workflows/ 0000775 0000000 0000000 00000000000 14566754545 0017462 5 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/.github/workflows/code-formatting.yml 0000664 0000000 0000000 00000001021 14566754545 0023261 0 ustar 00root root 0000000 0000000 name: Lint
on:
push:
branches: [ main ]
pull_request:
jobs:
black:
name: Code formatting with black
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: psf/black@stable
with:
src: ./isosurfaces
version: '23.9.1'
isort:
name: Import sorting with isort
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: isort/isort-action@v1.1.0
with:
isort-version: '5.12.0'
sort-paths: isosurfaces
isosurfaces-0.1.2/.gitignore 0000664 0000000 0000000 00000000077 14566754545 0016061 0 ustar 00root root 0000000 0000000 *.pyc
out
media
__pycache__
dist
build
*.egg-info
.mypy_cache/
isosurfaces-0.1.2/LICENSE 0000664 0000000 0000000 00000002055 14566754545 0015074 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2024 Jared Hughes
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
isosurfaces-0.1.2/README.md 0000664 0000000 0000000 00000004621 14566754545 0015347 0 ustar 00root root 0000000 0000000 # Isosurfaces
Construct isolines/isosurfaces of a 2D/3D scalar field defined by a function, i.e. curves over which `f(x,y)=0` or surfaces over which `f(x,y,z)=0`. Most similar libraries use marching squares or similar over a uniform grid, but this uses a quadtree to avoid wasting time sampling many far from the implicit surface.
This library is based on the approach described in [Manson, Josiah, and Scott Schaefer. "Isosurfaces over simplicial partitions of multiresolution grids." Computer Graphics Forum. Vol. 29. No. 2. Oxford, UK: Blackwell Publishing Ltd, 2010](https://people.engr.tamu.edu/schaefer/research/iso_simplicial.pdf).
An example graph, including quad lines, of `y(x-y)^2 = 4x+8` (Python expression: `y*(x-y)**2 - 4*x - 8`)
## Installation
```sh
pip3 install isosurfaces
```
## Usage
```py
from isosurfaces import plot_isoline
import numpy as np
def f(x, y):
return y * (x - y) ** 2 - 4 * x - 8
curves = plot_isoline(
lambda u: f(u[0], u[1]),
np.array([-8, -6]),
np.array([8, 6]),
# Increasing min_depth can help if you have small features
min_depth=3,
# Ensure max_quads is more than 4**min_depth to capture details better
# than a 2**min_depth by 2**min_depth uniform grid
max_quads=1000,
)
for curve in curves:
print(', '.join(f"({p[0]:.3f},{p[1]:.3f})" for p in curve))
```
## Dev examples
```sh
python3 isoline_demo.py && xdg-open out/demo.svg
manim -pql isosurface_demo.py --renderer=opengl --enable_gui
```
Pyflakes, allowing manim star imports
```sh
python3 -m pyflakes . | grep -v "star imports: manim"
```
Build source archive and wheel:
```sh
rm -rf dist build isosurfaces.egg-info
python3 setup.py sdist bdist_wheel
twine check dist/*
# for test:
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
# for actual
twine upload dist/*
```
## Code formatting
`isosurfaces` uses [`black`](https://github.com/psf/black) and [`isort`](https://github.com/PyCQA/isort). A Github Action will run to make sure it was applied.
## Related
Related projects:
- (2D, grid-based) https://pypi.org/project/meander/
- (2D, grid-based) https://pypi.org/project/contours/
- (Archived) https://github.com/AaronWatters/contourist
Other terms for an isoline:
- Contour
- Level curve
- Topographic map
isosurfaces-0.1.2/assets/ 0000775 0000000 0000000 00000000000 14566754545 0015367 5 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/assets/demo.svg 0000664 0000000 0000000 00001660274 14566754545 0017054 0 ustar 00root root 0000000 0000000
isosurfaces-0.1.2/isoline_demo.py 0000664 0000000 0000000 00000010331 14566754545 0017103 0 ustar 00root root 0000000 0000000 """ Code for demo-ing and experimentation. Prepare for a mess """
import os
import cairo
import numpy as np
from isosurfaces import plot_isoline
from isosurfaces.isoline import Cell, CurveTracer, Triangulator, build_tree
min_depth = 5
pmin = np.array([-8, -6])
pmax = np.array([8, 6])
def f(x, y):
return y * (x - y) ** 2 - 4 * x - 8
# Here we directly use plot_implicit internals in order to see the quadtree
fn = lambda u: f(u[0], u[1])
tol = (pmax - pmin) / 1000
quadtree = build_tree(2, fn, pmin, pmax, min_depth, 5000, tol)
triangles = Triangulator(quadtree, fn, tol).triangulate()
curves = CurveTracer(triangles, fn, tol).trace()
def g(x, y):
return x**3 - x - y**2
# Typical usage
curves1 = plot_isoline(
lambda u: g(u[0], u[1]),
pmin,
pmax,
min_depth=4,
max_quads=1000,
)
def h(x, y):
return x**4 + y**4 - np.sin(x) - np.sin(4 * y)
curves2 = plot_isoline(lambda u: h(u[0], u[1]), pmin, pmax, 4, 1000)
def tanm(x, y):
return np.tan(x**2 + y**2) - 1
curves3 = plot_isoline(lambda u: tanm(u[0], u[1]), pmin, pmax, 6, 5000)
WIDTH = 640
HEIGHT = 480
def setup_context(c):
# reflection to change math units to screen units
scale = min(WIDTH / (pmax[0] - pmin[0]), HEIGHT / (pmax[1] - pmin[1]))
c.scale(scale, -scale)
c.translate(WIDTH / scale / 2, -HEIGHT / scale / 2)
c.set_line_join(cairo.LINE_JOIN_BEVEL)
def draw_axes(c):
c.save()
c.set_line_width(0.1)
c.move_to(0, -100)
c.line_to(0, 100)
c.stroke()
c.move_to(-100, 0)
c.line_to(100, 0)
c.stroke()
c.restore()
def draw_quad(c, quad: Cell):
width = 0
if quad.depth <= min_depth:
width = 0.02
elif quad.depth == min_depth + 1:
width = 0.01
else:
width = 0.005
c.set_line_width(0.5 * width)
if quad.children:
c.move_to(*((quad.vertices[0].pos + quad.vertices[1].pos) / 2))
c.line_to(*((quad.vertices[2].pos + quad.vertices[3].pos) / 2))
c.move_to(*((quad.vertices[0].pos + quad.vertices[2].pos) / 2))
c.line_to(*((quad.vertices[1].pos + quad.vertices[3].pos) / 2))
c.stroke()
for child in quad.children:
draw_quad(c, child)
def draw_quads(c):
c.save()
draw_quad(c, quadtree)
c.restore()
def draw_triangles(c):
c.save()
c.set_line_width(0.001)
for tri in triangles:
c.move_to(*tri.vertices[0].pos)
c.line_to(*tri.vertices[1].pos)
c.line_to(*tri.vertices[2].pos)
c.line_to(*tri.vertices[0].pos)
c.stroke()
c.restore()
def draw_signs(c):
c.save()
for tri in triangles:
for vert in tri.vertices:
vert.drawn = False
for tri in triangles:
for vert in tri.vertices:
if vert.drawn:
continue
vert.drawn = True
if vert.val > 0:
c.set_source_rgb(0.2, 0.2, 1)
else:
c.set_source_rgb(1, 0.2, 0.2)
w = 0.01
c.rectangle(vert.pos[0] - w, vert.pos[1] - w, 2 * w, 2 * w)
c.fill()
c.restore()
def draw_bg(c):
c.save()
c.set_source_rgb(1, 1, 1)
c.paint()
c.restore()
def draw_curves(c, curves_list, rgb):
print("drawing", sum(map(len, curves_list)), "segments in", len(curves_list), "curves")
c.set_source_rgb(*rgb)
# draw curves
c.save()
c.set_line_width(0.03)
for curve in curves_list:
c.move_to(*curve[0])
for v in curve[1:]:
c.line_to(*v)
c.stroke()
c.restore()
def draw_curve_vertices(c, curves_list, rgb):
c.set_source_rgb(*rgb)
c.save()
w = 0.01
for curve in curves_list:
for v in curve:
c.rectangle(v[0] - w, v[1] - w, 2 * w, 2 * w)
c.fill()
c.restore()
if not os.path.exists("out"):
os.mkdir("out")
with cairo.SVGSurface("out/demo.svg", WIDTH, HEIGHT) as surface:
c = cairo.Context(surface)
setup_context(c)
draw_bg(c)
draw_axes(c)
# draw_quads(c)
# draw_triangles(c)
# draw_signs(c)
draw_curves(c, curves, [0.1, 0.1, 0.8])
# draw_curve_vertices(c, curves, [0.5, 0.8, 0.6])
draw_curves(c, curves1, [0.8, 0.1, 0.1])
draw_curves(c, curves2, [0.1, 0.6, 0.1])
# draw_curves(c, curves3, [0.1, 0.4, 0.5])
isosurfaces-0.1.2/isosurface_demo.py 0000664 0000000 0000000 00000003342 14566754545 0017610 0 ustar 00root root 0000000 0000000 import numpy as np
from manim import *
from isosurfaces import plot_isosurface
metaball_pts = [np.array([0, 1.6, 0]), np.array([0, -1.6, 0])]
def fn(p):
# metaballs
# return sum(1 / np.linalg.norm(p - q) for q in metaball_pts) - 1
# cone with singularity at origin
return p[0] ** 2 + p[1] ** 2 - p[2] ** 2
pmin = np.array([-4, -4, -4])
pmax = np.array([4, 4, 4])
simplices, faces = plot_isosurface(fn, pmin, pmax, 2, 64)
faces = list(faces)
class Isosurface(Surface):
def __init__(self, faces, **kwargs):
# Need the right resolution to trick the surface into rendering all of the faces
# Each face is a triangle (list of three points)
num_points = len(faces) * 3
super().__init__(uv_func=None, resolution=(num_points, 1), **kwargs)
s_points = [p for face in faces for p in face]
# du_points and dv_points are used to compute vertex normals
du_points = [p for face in faces for p in face[1:] + face[:1]]
dv_points = [p for face in faces for p in face[2:] + face[:2]]
# The three lists have equal length and are stored consecutively
self.set_points(s_points + du_points + dv_points)
# manim -pql isosurface_demo.py --renderer=opengl --enable_gui --fullscreen
class DemoScene(ThreeDScene):
def construct(self):
self.add(ThreeDAxes())
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
self.add(Isosurface(faces))
# sgroup = VGroup()
# for s in simplices[:16]:
# [a, b, c, d] = map(lambda p: p.pos, s)
# sgroup.add(
# Line(a, b), Line(b, c), Line(c, d), Line(d, a), Line(a, c), Line(b, d)
# )
# self.add(sgroup)
self.wait(20)
isosurfaces-0.1.2/isosurfaces/ 0000775 0000000 0000000 00000000000 14566754545 0016413 5 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/isosurfaces/__init__.py 0000664 0000000 0000000 00000000220 14566754545 0020516 0 ustar 00root root 0000000 0000000 __version__ = "0.1.2"
__all__ = ["plot_isoline", "plot_isosurface"]
from .isoline import plot_isoline
from .isosurface import plot_isosurface
isosurfaces-0.1.2/isosurfaces/cell.py 0000664 0000000 0000000 00000012510 14566754545 0017703 0 ustar 00root root 0000000 0000000 # to support Cell type inside Cell
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from typing import Iterator
import numpy as np
from .point import Func, Point, ValuedPoint
def vertices_from_extremes(dim: int, pmin: Point, pmax: Point, fn: Func) -> list[ValuedPoint]:
"""Requires pmin.x ≤ pmax.x, pmin.y ≤ pmax.y"""
w = pmax - pmin
return [
ValuedPoint(np.array([pmin[d] + (i >> d & 1) * w[d] for d in range(dim)])).calc(fn) for i in range(1 << dim)
]
@dataclass
class MinimalCell:
dim: int
# In 2 dimensions, vertices = [bottom-left, bottom-right, top-left, top-right] points
vertices: list[ValuedPoint]
def get_subcell(self, axis: int, dir: int) -> MinimalCell:
"""Given an n-cell, this returns an (n-1)-cell (with half the vertices)"""
m = 1 << axis
return MinimalCell(self.dim - 1, [v for i, v in enumerate(self.vertices) if (i & m > 0) == dir])
def get_dual(self, fn: Func) -> ValuedPoint:
return ValuedPoint.midpoint(self.vertices[0], self.vertices[-1], fn)
@dataclass
class Cell(MinimalCell):
depth: int
# Children go in same order: bottom-left, bottom-right, top-left, top-right
children: list[Cell]
parent: Cell
child_direction: int
def compute_children(self, fn: Func) -> None:
assert self.children == []
for i, vertex in enumerate(self.vertices):
pmin = (self.vertices[0].pos + vertex.pos) / 2
pmax = (self.vertices[-1].pos + vertex.pos) / 2
vertices = vertices_from_extremes(self.dim, pmin, pmax, fn)
new_quad = Cell(self.dim, vertices, self.depth + 1, [], self, i)
self.children.append(new_quad)
def get_leaves_in_direction(self, axis: int, dir: int) -> Iterator[Cell]:
"""
Axis = 0,1,2,etc for x,y,z,etc.
Dir = 0 for -x, 1 for +x.
"""
if self.children:
m = 1 << axis
for i in range(1 << self.dim):
if (i & m > 0) == dir:
yield from self.children[i].get_leaves_in_direction(axis, dir)
else:
yield self
def walk_in_direction(self, axis: int, dir: int) -> Cell | None:
"""
Same arguments as get_leaves_in_direction.
Returns the quad (with depth <= self.depth) that shares a (dim-1)-cell
with self, where that (dim-1)-cell is the side of self defined by
axis and dir.
"""
m = 1 << axis
if (self.child_direction & m > 0) == dir:
# on the right side of the parent cell and moving right (or analagous)
# so need to go up through the parent's parent
if self.parent is None:
return None
parent_walked = self.parent.walk_in_direction(axis, dir)
if parent_walked and parent_walked.children:
# end at same depth
return parent_walked.children[self.child_direction ^ m]
else:
# end at lesser depth
return parent_walked
else:
if self.parent is None:
return None
return self.parent.children[self.child_direction ^ m]
def walk_leaves_in_direction(self, axis: int, dir: int) -> Iterator[Cell | None]:
walked = self.walk_in_direction(axis, dir)
if walked is not None:
yield from walked.get_leaves_in_direction(axis, dir)
else:
yield None
def should_descend_deep_cell(cell: Cell, tol: np.ndarray) -> bool:
if np.all(cell.vertices[-1].pos - cell.vertices[0].pos < 10 * tol):
# too small of a cell to be worth descending
# We compare to 10*tol instead of tol because the simplices are smaller than the quads
# The factor 10 itself is arbitrary.
return False
elif all(np.isnan(v.val) for v in cell.vertices):
# in a region where the function is undefined
return False
elif any(np.isnan(v.val) for v in cell.vertices):
# straddling defined and undefined
return True
else:
# simple approach: only descend if we cross the isoline
# TODO: This could very much be improved, e.g. by incorporating gradient or second-derivative
# tests, etc., to cancel descending in approximately linear regions
return any(np.sign(v.val) != np.sign(cell.vertices[0].val) for v in cell.vertices[1:])
def build_tree(
dim: int,
fn: Func,
pmin: Point,
pmax: Point,
min_depth: int,
max_cells: int,
tol: np.ndarray,
) -> Cell:
branching_factor = 1 << dim
# min_depth takes precedence over max_quads
max_cells = max(branching_factor**min_depth, max_cells)
vertices = vertices_from_extremes(dim, pmin, pmax, fn)
# root's childDirection is 0, even though none is reasonable
current_quad = root = Cell(dim, vertices, 0, [], None, 0)
quad_queue = deque([root])
leaf_count = 1
while len(quad_queue) > 0 and leaf_count < max_cells:
current_quad = quad_queue.popleft()
if current_quad.depth < min_depth or should_descend_deep_cell(current_quad, tol):
current_quad.compute_children(fn)
quad_queue.extend(current_quad.children)
# add 4 for the new quads, subtract 1 for the old quad not being a leaf anymore
leaf_count += branching_factor - 1
return root
isosurfaces-0.1.2/isosurfaces/isoline.py 0000664 0000000 0000000 00000025147 14566754545 0020440 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .cell import Cell, build_tree
from .point import Func, Point, ValuedPoint, binary_search_zero
def plot_isoline(
fn: Func,
pmin: Point,
pmax: Point,
min_depth: int = 5,
max_quads: int = 10000,
tol: np.ndarray | None = None,
) -> list[list[Point]]:
"""Get the curve representing fn([x,y])=0 on pmin[0] ≤ x ≤ pmax[0] ∩ pmin[1] ≤ y ≤ pmax[1]
Returns as a list of curves, where each curve is a list of points"""
pmin = np.asarray(pmin)
pmax = np.asarray(pmax)
if tol is None:
tol = (pmax - pmin) / 1000
else:
tol = np.asarray(tol)
quadtree = build_tree(2, fn, pmin, pmax, min_depth, max_quads, tol)
triangles = Triangulator(quadtree, fn, tol).triangulate()
return CurveTracer(triangles, fn, tol).trace()
@dataclass
class Triangle:
vertices: list[ValuedPoint]
""" The order of triangle "next" is such that, when walking along the isoline in the direction of next,
you keep positive function values on your right and negative function values on your left."""
next: Triangle | None = None
next_bisect_point: ValuedPoint | None = None
prev: Triangle | None = None
visited: bool = False
def four_triangles(
a: ValuedPoint, b: ValuedPoint, c: ValuedPoint, d: ValuedPoint, center: ValuedPoint
) -> tuple[Triangle, Triangle, Triangle, Triangle]:
"""a,b,c,d should be clockwise oriented, with center on the inside of that quad"""
return (
Triangle([a, b, center]),
Triangle([b, c, center]),
Triangle([c, d, center]),
Triangle([d, a, center]),
)
class Triangulator:
"""While triangulating, also compute the isolines.
Divides each quad into 8 triangles from the quad's center. This simplifies
adjacencies between triangles for the general case of multiresolution quadtrees.
Based on Manson, Josiah, and Scott Schaefer. "Isosurfaces
over simplicial partitions of multiresolution grids." Computer Graphics Forum.
Vol. 29. No. 2. Oxford, UK: Blackwell Publishing Ltd, 2010.
(https://people.engr.tamu.edu/schaefer/research/iso_simplicial.pdf), but this
does not currently implement placing dual vertices based on the gradient.
"""
def __init__(self, root: Cell, fn: Func, tol: np.ndarray) -> None:
self.triangles: list[Triangle] = []
self.hanging_next: dict[bytes, Triangle] = {}
self.root = root
self.fn = fn
self.tol = tol
def triangulate(self) -> list[Triangle]:
self.triangulate_inside(self.root)
return self.triangles
def triangulate_inside(self, quad: Cell) -> None:
if quad.children:
for child in quad.children:
self.triangulate_inside(child)
self.triangulate_crossing_row(quad.children[0], quad.children[1])
self.triangulate_crossing_row(quad.children[2], quad.children[3])
self.triangulate_crossing_col(quad.children[0], quad.children[2])
self.triangulate_crossing_col(quad.children[1], quad.children[3])
def triangulate_crossing_row(self, a: Cell, b: Cell) -> None:
"""Quad b should be to the right (greater x values) than quad a"""
if a.children and b.children:
self.triangulate_crossing_row(a.children[1], b.children[0])
self.triangulate_crossing_row(a.children[3], b.children[2])
elif a.children:
self.triangulate_crossing_row(a.children[1], b)
self.triangulate_crossing_row(a.children[3], b)
elif b.children:
self.triangulate_crossing_row(a, b.children[0])
self.triangulate_crossing_row(a, b.children[2])
else:
# a and b are minimal 2-cells
face_dual_a = self.get_face_dual(a)
face_dual_b = self.get_face_dual(b)
# Add the four triangles from the centers of a and b to the shared edge between them
if a.depth < b.depth:
# b is smaller
edge_dual = self.get_edge_dual(b.vertices[2], b.vertices[0])
triangles = four_triangles(b.vertices[2], face_dual_b, b.vertices[0], face_dual_a, edge_dual)
else:
edge_dual = self.get_edge_dual(a.vertices[3], a.vertices[1])
triangles = four_triangles(a.vertices[3], face_dual_b, a.vertices[1], face_dual_a, edge_dual)
self.add_four_triangles(triangles)
def triangulate_crossing_col(self, a: Cell, b: Cell) -> None:
"""Mostly a copy-paste of triangulate_crossing_row. For n-dimensions, want to pass a
dir index into a shared triangulate_crossing_dir function instead"""
if a.children and b.children:
self.triangulate_crossing_col(a.children[2], b.children[0])
self.triangulate_crossing_col(a.children[3], b.children[1])
elif a.children:
self.triangulate_crossing_col(a.children[2], b)
self.triangulate_crossing_col(a.children[3], b)
elif b.children:
self.triangulate_crossing_col(a, b.children[0])
self.triangulate_crossing_col(a, b.children[1])
else:
# a and b are minimal 2-cells
face_dual_a = self.get_face_dual(a)
face_dual_b = self.get_face_dual(b)
# Add the four triangles from the centers of a and b to the shared edge between them
if a.depth < b.depth:
# b is smaller
edge_dual = self.get_edge_dual(b.vertices[0], b.vertices[1])
triangles = four_triangles(b.vertices[0], face_dual_b, b.vertices[1], face_dual_a, edge_dual)
else:
edge_dual = self.get_edge_dual(a.vertices[2], a.vertices[3])
triangles = four_triangles(a.vertices[2], face_dual_b, a.vertices[3], face_dual_a, edge_dual)
self.add_four_triangles(triangles)
def add_four_triangles(self, triangles: tuple[Triangle, Triangle, Triangle, Triangle]) -> None:
for i in range(4):
self.next_sandwich_triangles(triangles[i], triangles[(i + 1) % 4], triangles[(i + 2) % 4])
self.triangles.extend(triangles)
def set_next(self, tri1: Triangle, tri2: Triangle, vpos: ValuedPoint, vneg: ValuedPoint) -> None:
if not vpos.val > 0 >= vneg.val:
return
intersection, is_zero = binary_search_zero(vpos, vneg, self.fn, self.tol)
if not is_zero:
return
tri1.next_bisect_point = intersection
tri1.next = tri2
tri2.prev = tri1
def next_sandwich_triangles(self, a: Triangle, b: Triangle, c: Triangle) -> None:
"""Find the "next" triangle for the triangle b. See Triangle for a description of the curve orientation.
We assume the triangles are oriented such that they share common vertices center←a[2]≡b[2]≡c[2]
and x←a[1]≡b[0], y←b[1]≡c[0]"""
center = b.vertices[2]
x = b.vertices[0]
y = b.vertices[1]
# Simple connections: inside the same four triangles
# (Group 0 with negatives)
if center.val > 0 >= y.val:
self.set_next(b, c, center, y)
# (Group 0 with negatives)
if x.val > 0 >= center.val:
self.set_next(b, a, x, center)
# More difficult connections: complete a hanging connection
# or wait for another triangle to complete this
# We index using (double) the midpoint of the hanging edge
id = (x.pos + y.pos).data.tobytes()
# (Group 0 with negatives)
if y.val > 0 >= x.val:
if id in self.hanging_next:
self.set_next(b, self.hanging_next[id], y, x)
del self.hanging_next[id]
else:
self.hanging_next[id] = b
elif y.val <= 0 < x.val:
if id in self.hanging_next:
self.set_next(self.hanging_next[id], b, x, y)
del self.hanging_next[id]
else:
self.hanging_next[id] = b
def get_edge_dual(self, p1: ValuedPoint, p2: ValuedPoint) -> ValuedPoint:
"""Returns the dual point on an edge p1--p2"""
if (p1.val > 0) != (p2.val > 0):
# The edge crosses the isoline, so take the midpoint
return ValuedPoint.midpoint(p1, p2, self.fn)
dt = 0.01
# We intersect the planes with normals <∇f(p1), -1> and <∇f(p2), -1>
# move slightly from p1 to p2. df = ∆f, so ∆f/∆t = 100*df1 near p1
df1 = self.fn(p1.pos * (1 - dt) + p2.pos * dt)
# move slightly from p2 to p1. df = ∆f, so ∆f/∆t = -100*df2 near p2
df2 = self.fn(p1.pos * dt + p2.pos * (1 - dt))
# (Group 0 with negatives)
if (df1 > 0) == (df2 > 0):
# The function either increases → ← or ← →, so a lerp would shoot out of bounds
# Take the midpoint
return ValuedPoint.midpoint(p1, p2, self.fn)
else:
# Increases → 0 → or ← 0 ←
v1 = ValuedPoint(p1.pos, df1)
v2 = ValuedPoint(p2.pos, df2)
return ValuedPoint.intersectZero(v1, v2, self.fn)
def get_face_dual(self, quad: Cell) -> ValuedPoint:
# TODO: proper face dual
return ValuedPoint.midpoint(quad.vertices[0], quad.vertices[-1], self.fn)
class CurveTracer:
active_curve: list[ValuedPoint]
def __init__(self, triangles: list[Triangle], fn: Func, tol: np.ndarray) -> None:
self.triangles = triangles
self.fn = fn
self.tol = tol
def trace(self) -> list[list[Point]]:
curves: list[list[ValuedPoint]] = []
for triangle in self.triangles:
if not triangle.visited and triangle.next is not None:
self.active_curve = []
self.march_triangle(triangle)
# triangle.next is not None, so there should be at least one segment
curves.append(self.active_curve)
return [[v.pos for v in curve] for curve in curves]
def march_triangle(self, triangle: Triangle) -> None:
start_triangle = triangle
closed_loop = False
# Iterate backwards to the start of a connected curve
while triangle.prev is not None:
triangle = triangle.prev
if triangle is start_triangle:
closed_loop = True
break
while triangle is not None and not triangle.visited:
if triangle.next_bisect_point is not None:
self.active_curve.append(triangle.next_bisect_point)
triangle.visited = True
triangle = triangle.next
if closed_loop:
# close back the loop
self.active_curve.append(self.active_curve[0])
isosurfaces-0.1.2/isosurfaces/isosurface.py 0000664 0000000 0000000 00000010657 14566754545 0021141 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import Iterator
import numpy as np
from .cell import Cell, MinimalCell, build_tree
from .point import Func, Point, ValuedPoint, binary_search_zero
def plot_isosurface(
fn: Func,
pmin: Point,
pmax: Point,
min_depth: int = 5,
max_cells: int = 10000,
tol: np.ndarray | None = None,
):
"""Returns the surface representing fn([x,y,z])=0 on
pmin[0] ≤ x ≤ pmax[0] ∩ pmin[1] ≤ y ≤ pmax[1] ∩ pmin[2] ≤ z ≤ pmax[2]"""
pmin = np.asarray(pmin)
pmax = np.asarray(pmax)
if tol is None:
tol = (pmax - pmin) / 1000
else:
tol = np.asarray(tol)
octtree = build_tree(3, fn, pmin, pmax, min_depth, max_cells, tol)
simplices = list(SimplexGenerator(octtree, fn).get_simplices())
faces = []
for simplex in simplices:
face_list = march_simplex(simplex, fn, tol)
if face_list is not None:
faces.extend(face_list)
return simplices, faces
TETRAHEDRON_TABLE: dict[int, list[tuple[int, int]]] = {
0b0000: [], # falsey
0b0001: [(0, 3), (1, 3), (2, 3)],
0b0010: [(0, 2), (1, 2), (3, 2)],
0b0100: [(0, 1), (2, 1), (3, 1)],
0b1000: [(1, 0), (2, 0), (3, 0)],
0b0011: [(0, 2), (2, 1), (1, 3), (3, 0)],
0b0110: [(0, 1), (1, 3), (3, 2), (2, 0)],
0b0101: [(0, 1), (1, 2), (2, 3), (3, 0)],
}
def march_indices(simplex: list[ValuedPoint]) -> list[tuple[int, int]]:
"""Assumes the simplex is a tetrahedron, so this is marching tetrahedrons"""
id = 0
for p in simplex:
# (Group 0 with negatives)
id = 2 * id + (p.val > 0)
if id in TETRAHEDRON_TABLE:
return TETRAHEDRON_TABLE[id]
else:
return TETRAHEDRON_TABLE[0b1111 ^ id]
def march_simplex(
simplex: list[ValuedPoint], fn: Func, tol: np.ndarray
) -> list[list[Point]] | tuple[list[Point], list[Point]]:
indices = march_indices(simplex)
if indices:
points: list[Point] = []
for i, j in indices:
intersection, is_zero = binary_search_zero(simplex[i], simplex[j], fn, tol)
assert is_zero
points.append(intersection.pos)
if len(points) == 3:
# Single triangle
return [points]
else:
# quadrilateral (two triangles)
return [points[0], points[1], points[3]], [points[1], points[2], points[3]]
class SimplexGenerator:
def __init__(self, root: Cell, fn: Func) -> None:
self.root = root
self.fn = fn
def get_simplices(self) -> Iterator[list[ValuedPoint]]:
return self.get_simplices_within(self.root)
def get_simplices_within(self, oct: Cell) -> Iterator[list[ValuedPoint]]:
if oct.children:
for child in oct.children:
yield from self.get_simplices_within(child)
else:
for axis in [0, 1, 2]:
for dir in [0, 1]:
adj = oct.walk_leaves_in_direction(axis, dir)
for leaf in adj:
if leaf is None:
# e.g. this is the rightmost cell with direction to the right
yield from self.get_simplices_between_face(oct, oct.get_subcell(axis, dir))
else:
yield from self.get_simplices_between(oct, leaf, axis, dir)
def get_simplices_between(self, a: Cell, b: Cell, axis: int, dir: int) -> Iterator[list[ValuedPoint]]:
"""
Parameters axis and dir are same as Cell.get_leaves_in_direction.
They denote the direction a→b
"""
if a.depth > b.depth:
[a, b] = [b, a]
dir = 1 - dir
# Now b is the same depth or deeper (smaller) than a
face = b.get_subcell(axis, 1 - dir)
for volume in [a, b]:
yield from self.get_simplices_between_face(volume, face)
def get_simplices_between_face(self, volume: Cell, face: MinimalCell) -> Iterator[list[ValuedPoint]]:
# Each simplex comes from:
# 1 volume dual
# 1 face dual
# 1 edge dual (of an edge of their shared face)
# 1 vertex dual (of a vertex of that edge)
for i in range(4):
edge = face.get_subcell(i % 2, i // 2)
for v in edge.vertices:
yield [
volume.get_dual(self.fn),
face.get_dual(self.fn),
edge.get_dual(self.fn),
v,
]
isosurfaces-0.1.2/isosurfaces/point.py 0000664 0000000 0000000 00000003767 14566754545 0020133 0 ustar 00root root 0000000 0000000 # to support ValuedPoint type inside ValuedPoint
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
import numpy as np
Point = np.ndarray
Func = Callable[[Point], float]
@dataclass
class ValuedPoint:
"""A position associated with the corresponding function value"""
pos: Point
val: float = None
def calc(self, fn: Func) -> ValuedPoint:
self.val = fn(self.pos)
return self
def __repr__(self) -> str:
return f"({self.pos[0]},{self.pos[1]}; {self.val})"
@classmethod
def midpoint(cls, p1: ValuedPoint, p2: ValuedPoint, fn: Func) -> ValuedPoint:
mid = (p1.pos + p2.pos) / 2
return cls(mid, fn(mid))
@classmethod
def intersectZero(cls, p1: ValuedPoint, p2: ValuedPoint, fn: Func) -> ValuedPoint:
"""Find the point on line p1--p2 with value 0"""
denom = p1.val - p2.val
k1 = -p2.val / denom
k2 = p1.val / denom
pt = k1 * p1.pos + k2 * p2.pos
return cls(pt, fn(pt))
def binary_search_zero(p1: ValuedPoint, p2: ValuedPoint, fn: Func, tol: np.ndarray) -> tuple[ValuedPoint, bool]:
"""Returns a pair `(point, is_zero: bool)`
Use is_zero to make sure it's not an asymptote like at x=0 on f(x,y) = 1/(xy) - 1"""
if np.all(np.abs(p2.pos - p1.pos) < tol):
# Binary search stop condition: too small to matter
pt = ValuedPoint.intersectZero(p1, p2, fn)
is_zero: bool = pt.val == 0 or (
np.sign(pt.val - p1.val) == np.sign(p2.val - pt.val)
# Just want to prevent ≈inf from registering as a zero
and np.abs(pt.val < 1e200)
)
return pt, is_zero
else:
# binary search
mid = ValuedPoint.midpoint(p1, p2, fn)
if mid.val == 0:
return mid, True
# (Group 0 with negatives)
elif (mid.val > 0) == (p1.val > 0):
return binary_search_zero(mid, p2, fn, tol)
else:
return binary_search_zero(p1, mid, fn, tol)
isosurfaces-0.1.2/isosurfaces/py.typed 0000664 0000000 0000000 00000000000 14566754545 0020100 0 ustar 00root root 0000000 0000000 isosurfaces-0.1.2/pyproject.toml 0000664 0000000 0000000 00000000305 14566754545 0016777 0 ustar 00root root 0000000 0000000 [tool.black]
target-version = ["py38", "py39", "py310", "py311"]
line-length = 120
include = "isosurfaces/|.*_demo.py"
[tool.isort]
profile = "black"
src_paths = ["isosurfaces"]
line_length = 120
isosurfaces-0.1.2/setup.py 0000664 0000000 0000000 00000002643 14566754545 0015604 0 ustar 00root root 0000000 0000000 import pathlib
from setuptools import setup
# The directory containing this file
HERE = pathlib.Path(__file__).parent
# The text of the README file
README = (
(HERE / "README.md")
.read_text()
.replace(
# use jsdelivr here to work inside PyPI description
'src="assets/demo.svg"',
'src="https://cdn.jsdelivr.net/gh/jared-hughes/isosurfaces/assets/demo.svg"',
)
)
# This call to setup() does all the work
setup(
name="isosurfaces",
version="0.1.2",
description="Construct isolines/isosurfaces over a 2D/3D scalar field defined by a function (not a uniform grid)",
long_description=README,
long_description_content_type="text/markdown",
license_files=("LICENSE",),
url="https://github.com/jared-hughes/isosurfaces",
author="Jared Hughes",
author_email="jahughes.dev@gmail.com",
license="MIT",
classifiers=[
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Typing :: Typed",
],
python_requires=">=3.8",
packages=["isosurfaces"],
include_package_data=True,
package_data={"isosurfaces": ["py.typed"]},
install_requires=["numpy"],
)