pax_global_header00006660000000000000000000000064145665103670014527gustar00rootroot0000000000000052 comment=385744fb16bf36d53f15ca3abbbbd476d95146ff isosurfaces-0.1.1/000077500000000000000000000000001456651036700140545ustar00rootroot00000000000000isosurfaces-0.1.1/.github/000077500000000000000000000000001456651036700154145ustar00rootroot00000000000000isosurfaces-0.1.1/.github/workflows/000077500000000000000000000000001456651036700174515ustar00rootroot00000000000000isosurfaces-0.1.1/.github/workflows/code-formatting.yml000066400000000000000000000010211456651036700232500ustar00rootroot00000000000000name: 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.1/.gitignore000066400000000000000000000000771456651036700160500ustar00rootroot00000000000000*.pyc out media __pycache__ dist build *.egg-info .mypy_cache/ isosurfaces-0.1.1/LICENSE000066400000000000000000000020551456651036700150630ustar00rootroot00000000000000MIT 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.1/README.md000066400000000000000000000046241456651036700153410ustar00rootroot00000000000000# 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`) Demo with grid lines ## 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 assets/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.1/assets/000077500000000000000000000000001456651036700153565ustar00rootroot00000000000000isosurfaces-0.1.1/assets/demo.svg000066400000000000000000016602741456651036700170430ustar00rootroot00000000000000 isosurfaces-0.1.1/isoline_demo.py000066400000000000000000000103331456651036700170740ustar00rootroot00000000000000""" 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.makedir("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.1/isosurface_demo.py000066400000000000000000000033421456651036700175770ustar00rootroot00000000000000import 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.1/isosurfaces/000077500000000000000000000000001456651036700164025ustar00rootroot00000000000000isosurfaces-0.1.1/isosurfaces/__init__.py000066400000000000000000000002201456651036700205050ustar00rootroot00000000000000__version__ = "0.1.1" __all__ = ["plot_isoline", "plot_isosurface"] from .isoline import plot_isoline from .isosurface import plot_isosurface isosurfaces-0.1.1/isosurfaces/cell.py000066400000000000000000000125101456651036700176720ustar00rootroot00000000000000# 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.1/isosurfaces/isoline.py000066400000000000000000000251471456651036700204270ustar00rootroot00000000000000from __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.1/isosurfaces/isosurface.py000066400000000000000000000106571456651036700211300ustar00rootroot00000000000000from __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.1/isosurfaces/point.py000066400000000000000000000037671456651036700201220ustar00rootroot00000000000000# 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.1/isosurfaces/py.typed000066400000000000000000000000001456651036700200670ustar00rootroot00000000000000isosurfaces-0.1.1/pyproject.toml000066400000000000000000000003051456651036700167660ustar00rootroot00000000000000[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.1/setup.py000066400000000000000000000026431456651036700155730ustar00rootroot00000000000000import 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.1", 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"], )