Guide
This guide walks through the core workflow: importing a surface, generating volume points, optimizing the distribution, and preparing the point cloud for a meshless solver.
Importing a Surface
Mesh files carry no unit metadata — the coordinates are just numbers, and CAD tools commonly export in millimeters or inches. Every file entry point therefore requires the unit those numbers are meant in; inspect first when unsure:
using WhatsThePoint
using Unitful: mm
geometry_info("intake.stl")
# ─── intake.stl ───
# min: (0.0, 0.0, 0.0)
# max: (120.5, 87.3, 42.0) ← looks like mm
# extent: (120.5, 87.3, 42.0)
boundary = PointBoundary("intake.stl", mm) # 120.5 becomes 120.5 mm, not 0.1205 mmThe unit reinterprets the stored numbers — no conversion happens. When the mesh itself is needed downstream (octrees, surface sampling), load it once through import_mesh and share it:
mesh = import_mesh("model.stl", mm)
boundary = PointBoundary(mesh)
octree = TriangleOctree(mesh; classify_leaves=true)When importing a mesh, WhatsThePoint uses face centers as boundary points rather than mesh vertices. Each face becomes a SurfaceElement storing its center point, outward normal, and area. This gives a more uniform boundary representation than raw vertices.
To place boundary points at a spacing you choose instead of the one the tessellation dictates, Poisson-disk sample the surface:
mesh = import_mesh("model.stl", mm)
boundary = PointBoundary(mesh, spacing) # blue-noise samples at spacing(x)This throws darts on the continuous triangle surface (see sample_surface), producing evenly spaced wall points that also remove tessellation artifacts like near-coincident face centers at sphere poles.
Inspecting the Boundary
# Number of boundary points
length(boundary)
# Access named surfaces
names(boundary) # surface names
surf = surfaces(boundary)[1] # first surface
# Surface element data
points(surf) # point coordinates
normal(surf) # normal vectors
area(surf) # element areasSurface Operations
Identify distinct geometric faces (walls, inlets, outlets) so you can apply different boundary conditions to each. Split surfaces at normal angle discontinuities:
split_surface!(boundary, 75°)This builds a k-nearest neighbor graph on the surface, removes edges where adjacent normals differ by more than the threshold angle, and labels each connected component as a separate named surface. See the Boundary & Normals page for details on normals, splitting, shadow points, and more.
Combine multiple surfaces back into one:
combine_surfaces!(boundary, "surface_1", "surface_2")Shadow Points (Optional)
Virtual points offset inward from the boundary, used in Hermite-type boundary condition enforcement (e.g., Hermite RBF-FD). Shadow points sit just inside the domain along the inward normal direction.
shadow = ShadowPoints(0.5mm)
shadow_pts = generate_shadows(surf, shadow)See the Boundary & Normals page for more shadow point options including variable offsets.
Generating Volume Points
Generate volume points from a boundary using discretize. The algorithm choice depends on the problem dimension.
Before committing to a spacing, probe the geometry:
g = suggest_spacing("model.stl", u"m") # extent, volume, h_ceiling / h_baseline / h_fine
spacing = ConstantSpacing(g.h_baseline)3D Algorithms
spacing = ConstantSpacing(1mm)
# SlakKosec (default for 3D) — sphere-based candidate generation
cloud = discretize(boundary, spacing; alg=SlakKosec())
# VanDerSandeFornberg — grid projection with sphere packing
cloud = discretize(boundary, spacing; alg=VanDerSandeFornberg(), max_points=100_000)
# Octree — spacing-driven adaptive fill; the default :bridson placement is a
# global graded Poisson-disk front, and max_points is auto-estimated when unset
mesh = import_mesh("model.stl", u"m")
bl_spacing = BoundaryLayerSpacing(points(boundary); at_wall=0.6m, bulk=4.0m, layer_thickness=8.0m)
cloud = discretize(boundary, bl_spacing; alg=Octree(mesh))SlakKosec can also accept a TriangleOctree for accelerated point-in-volume queries:
octree = TriangleOctree(mesh; min_ratio=1e-6)
cloud = discretize(boundary, spacing; alg=SlakKosec(octree))2D Algorithm
# FornbergFlyer (default and only option for 2D)
cloud = discretize(boundary, spacing; alg=FornbergFlyer())Spacing Types
Control point density with different spacing strategies:
# Uniform spacing everywhere
spacing = ConstantSpacing(1mm)After an initial discretization, you can use variable spacing for a second pass:
# Variable spacing — denser near boundary, coarser in interior
# Requires an existing PointCloud (uses boundary distances internally)
spacing = LogLike(cloud, 0.5mm, 1.2) # base_size, growth_rate
cloud = discretize(boundary, spacing; alg=SlakKosec())See the Discretization page for detailed descriptions of each algorithm and spacing type. For a complete runnable example, see examples/octreeboundarylayer.jl.
Node Repulsion (Optional)
The Octree algorithm's default Bridson placement delivers blue-noise quality by construction, so repulsion is optional polish there; for the other algorithms it refines approximate uniformity to minimize interpolation error in the meshless solver.
# Volume-only — boundary points stay fixed, escaped volume points are removed
cloud = repel(cloud, spacing; β=0.2, max_iters=1000)
# Boundary-aware (3D) — all points move, escaped points projected back to surface
octree = TriangleOctree(import_mesh("model.stl", u"m"); classify_leaves=true)
cloud = repel(cloud, spacing, octree; β=0.2, max_iters=1000)
# Collect convergence history via keyword
conv = Float64[]
cloud = repel(cloud, spacing, octree; β=0.2, max_iters=1000, convergence=conv)The returned cloud has NoTopology since points have moved.
The default parameters (β=0.2, k=21, max_iters=1000) work well for most problems. By default the relaxation stops once the spacing quality stalls (stall_after=50); set a cv_target to stop at a chosen quality instead. See the Node Repulsion page for detailed parameter guidance.
Verifying Distribution Quality
Use metrics and spacing_fidelity_metrics to inspect the point distribution before and after repulsion:
metrics(cloud) # neighbor distances, separation, fill, mesh ratio
spacing_fidelity_metrics(cloud, spacing) # d_NN/h statistics: mean, CV, percentiles, coordinationmetrics reports neighbor-distance statistics plus the global separation (smallest nearest-neighbor distance), fill (largest), and their ratio; spacing_fidelity_metrics measures how closely the actual local spacing matches the prescribed h(x) — the numbers that matter for meshless stencil conditioning.
Topology (Point Connectivity)
Meshless solvers compute derivatives using local neighborhoods. Topology pre-computes these neighborhoods so they are ready when the solver needs them.
# k-nearest neighbors
cloud = set_topology(cloud, KNNTopology, 21)
# Or radius-based neighbors
cloud = set_topology(cloud, RadiusTopology, 2mm)
# Access neighbor indices
neighbors(cloud, 5) # neighbors of point 5
neighbors(cloud) # all neighborhoods
# Check state
hastopology(cloud) # trueTopology can also be set on individual surfaces and volumes:
surf = set_topology(surf, KNNTopology, 10)
neighbors(surf, i) # local indices within the surfaceTopology on a PointSurface or PointVolume uses local indices (1 through length(surf)). Topology on a PointCloud uses global indices (1 through length(cloud)) where boundary points come first, followed by volume points. See Concepts for details.
Visualization
Visualize point clouds and boundaries using Makie.jl:
using GLMakie
visualize(cloud; markersize=0.15)
visualize(boundary; markersize=0.15)Export
Write a ParaView-ready .vtu (open it and set Representation to Point Gaussian). Every point carries point_type (boundary vs volume), surface_id (colour by named surface), and normals; after solving, attach your result arrays as fields:
export_vtk("cloud", cloud) # geometry
export_vtk("sol", cloud; fields = ("T" => temp, "U" => u)) # with solution datasave("output", cloud; format = :vtk) remains as a thin wrapper, and format = :jld2 (the default) serializes the cloud itself.