Skip to content

add polyhedron support.#497

Merged
julialongtin merged 28 commits intomasterfrom
add_polyhedron
Mar 19, 2026
Merged

add polyhedron support.#497
julialongtin merged 28 commits intomasterfrom
add_polyhedron

Conversation

@julialongtin
Copy link
Member

No description provided.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds initial polyhedron support across the core symbolic object model, SDF evaluation, OpenSCAD-like frontend, and OpenSCAD exporter.

Changes:

  • Introduces Polyhedron as a new SymbolicObj3 primitive and exposes a polyhedron constructor in Graphics.Implicit.Primitives.
  • Adds triangle/mesh utilities (Graphics.Implicit.TriUtil) and implements polyhedron distance evaluation and bounding-box calculation.
  • Extends the ExtOpenScad pipeline (primitives, OVal definitions, statement eval, docgen) to support polyhedron modules and suite-vs-no-suite module variants.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
tests/Graphics/Implicit/Test/Instances.hs Adds Arbitrary ℕ to support new polyhedron-related types in tests.
programs/docgen.hs Updates doc generation to handle the new ONModuleWithSuite variant.
implicit.cabal Registers new internal module Graphics.Implicit.TriUtil and adds build-option comments.
Makefile Improves examples target logging by echoing full command lines.
Graphics/Implicit/TriUtil.hs New triangle utilities: closest-feature, point-to-triangle distance, triangle normal, etc.
Graphics/Implicit/Primitives.hs Exports and defines the polyhedron primitive constructor.
Graphics/Implicit/ObjectUtil/GetImplicit3.hs Implements SDF evaluation for Polyhedron using triangle distances and inside/outside classification.
Graphics/Implicit/ObjectUtil/GetBox3.hs Implements bounding box computation for Polyhedron.
Graphics/Implicit/ExtOpenScad/Util/OVal.hs Adds type-string support for ONModuleWithSuite.
Graphics/Implicit/ExtOpenScad/Primitives.hs Adds polyhedron module and refactors built-in modules into suite vs non-suite variants.
Graphics/Implicit/ExtOpenScad/Eval/Statement.hs Updates module call evaluation to support ONModuleWithSuite.
Graphics/Implicit/ExtOpenScad/Definitions.hs Extends OVal with ONModuleWithSuite and updates Show/exports accordingly.
Graphics/Implicit/Export/TextBuilderUtils.hs Adds bℕ helper for emitting ℕ values in exporters.
Graphics/Implicit/Export/SymbolicFormats.hs Adds OpenSCAD export support for Polyhedron.
Graphics/Implicit/Definitions.hs Adds Polyhedron constructor to SymbolicObj3 and its Show instance.
Graphics/Implicit/Canon.hs Extends canonicalization/equality machinery to include Polyhedron.
Examples/example26-Polyhedron.scad Adds an example demonstrating polyhedron usage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +132 to +135
-- Exactly on an edge, don't bother dividing by zero, please.
| denom == 0 = (FeatFace, p)
-- On the triangle's surface
| otherwise = (FeatFace, v1 + v *^ vec12 + w *^ vec13)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When denom == 0, closestFeatureToTriangle returns (FeatFace, p), which makes distancePointToTriangle report zero distance even if the triangle is degenerate (or the point is not actually on the triangle). Handle denom == 0 by falling back to a segment/vertex distance computation for degenerate triangles instead of returning the input point.

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +224
arbitrary = do
n <- getPositive <$> arbitrary
return n

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Arbitrary ℕ instance is recursively defined: getPositive <$> arbitrary requires Arbitrary (Positive ℕ), which in turn requires Arbitrary ℕ, causing infinite recursion when generating values. Generate a base numeric type (e.g., Integer/Int) and convert with toℕ, or use choose to build a non-negative directly.

Suggested change
arbitrary = do
n <- getPositive <$> arbitrary
return n
arbitrary =
(fromInteger . getNonNegative) <$> (arbitrary :: Gen (NonNegative Integer))

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +136
Just (ONModuleWithSuite _ implementation forms) -> do
possibleInstances <- selectInstances forms
let
when (null possibleInstances) $ do
errorC sourcePos $ "no instance of " <> name <> " found to match given parameters.\nInstances available:\n" <> pack (show (ONModuleWithSuite (Symbol name) implementation forms))
traverse_ ((`checkOptions` True) . Just) forms
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a stray let with no bindings before the when statement in the ONModuleWithSuite branch, which is a parse/compile error. Remove the let or add the missing bindings.

Copilot uses AI. Check for mistakes.
, pretty $ bf h
] []

buildS3 _ (Polyhedron points tris) = callNaked "polyhedron" ["points = [" <> (fold $ intersperse "," $ renderPoint <$> points) <> "] faces = [" <> (fold $ intersperse "," $ renderTri <$> tris) <> "]" ] []
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated OpenSCAD for polyhedron concatenates points and faces into a single argument string without a separating comma, which will produce invalid OpenSCAD (arguments must be comma-separated). Pass points=... and faces=... as separate entries in the argument list (or insert a comma between them).

Suggested change
buildS3 _ (Polyhedron points tris) = callNaked "polyhedron" ["points = [" <> (fold $ intersperse "," $ renderPoint <$> points) <> "] faces = [" <> (fold $ intersperse "," $ renderTri <$> tris) <> "]" ] []
buildS3 _ (Polyhedron points tris) =
callNaked "polyhedron"
[ "points = [" <> (fold $ intersperse "," $ renderPoint <$> points) <> "]"
, "faces = [" <> (fold $ intersperse "," $ renderTri <$> tris) <> "]"
] []

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +138
where
unsignedDistanceAndTriangleClosestTo point = minimumBy (\((_,a),_) ((_,b),_) -> a `compare` b) $ featDistTriangles point
featDistTriangles point = (\a -> (distancePointToTriangle point (findTriangle points a), a)) <$> tris
firstPointOfTri (v1,_,_) = v1
pointOnOutside :: ℝ3 -> Triangle -> Tri -> ClosestFeature -> Bool
pointOnOutside point closestTriangle closestTri feature = (point - firstPointOfTri closestTriangle) `dot` (weighedNormish closestTri feature) >= -eps
where
-- fudge factor.
eps :: ℝ
eps = 1e-13
triSeq = fromList tris
-- For each edge, the tri indexes that share that edge:
triByEdge :: Map (ℕ,ℕ) [Int]
triByEdge = fromListWith (++) edgeTris
where
edgeTris = concatMap edgesOfTri $ toList $ mapWithIndex (,) triSeq
edgesOfTri :: (Int,Tri) -> [((ℕ,ℕ),[Int])]
edgesOfTri (i,(p1,p2,p3)) = [(sortEdge p1 p2,[i]),(sortEdge p2 p3,[i]),(sortEdge p3 p1,[i])]
sortEdge a b = (min a b, max a b)
-- For each vertex, the tri indexes that contain that vertex:
triByVertex :: Map ℕ [Int]
triByVertex = fromListWith (++) vertexTris
where
vertexTris = concatMap vertexesOfTri $ toList $ mapWithIndex (,) triSeq
vertexesOfTri :: (Int,Tri) -> [(ℕ,[Int])]
vertexesOfTri (i,(p1,p2,p3)) = [(p1,[i]),(p2,[i]),(p3,[i])]
-- Get the normalized average of a set of triangles, referred to by index.
averageNorm triIndexes = Linear.normalize $ sum $ normOfTriangle . genericIndex triangles <$> triIndexes
weighedNormish :: Tri -> ClosestFeature -> ℝ3
weighedNormish (p1,p2,p3) closest
| closest == FeatFace = normOfTriangle closestTriangle
| closest == FeatEdge12 = averageNorm $ fromMaybe [] $ lookup (sortEdge p1 p2) triByEdge
| closest == FeatEdge13 = averageNorm $ fromMaybe [] $ lookup (sortEdge p1 p3) triByEdge
| closest == FeatEdge23 = averageNorm $ fromMaybe [] $ lookup (sortEdge p2 p3) triByEdge
| closest == FeatVertex1 = Linear.normalize $ sum $ angleWeighed (genericIndex points p1) <$> fromMaybe [] (lookup p1 triByVertex)
| closest == FeatVertex2 = Linear.normalize $ sum $ angleWeighed (genericIndex points p2) <$> fromMaybe [] (lookup p2 triByVertex)
| closest == FeatVertex3 = Linear.normalize $ sum $ angleWeighed (genericIndex points p3) <$> fromMaybe [] (lookup p3 triByVertex)
| otherwise = normOfTriangle closestTriangle
angleWeighed :: ℝ3 -> Int -> ℝ3
angleWeighed vertex triNo = angleAt vertex triangle *^ normOfTriangle triangle
where
triangle = findTriangle points $ genericIndex tris triNo
-- decompose our tris into triangles.
triangles = findTriangle points <$> tris
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pointOnOutside rebuilds triSeq, triByEdge, triByVertex, triangles, etc. on every distance query, even though they only depend on points/tris. Since getImplicit3’s result is called many times during meshing, this will be a major slowdown. Precompute these structures once (outside the returned \point -> ...) and reuse them per query.

Copilot uses AI. Check for mistakes.
Comment on lines 117 to +131
Just (ONModule _ implementation forms) -> do
possibleInstances <- selectInstances forms
let
suiteInfo = case possibleInstances of
[(_, suiteInfoFound)] -> suiteInfoFound
[] -> Nothing
((_, suiteInfoFound):_) -> suiteInfoFound
when (null possibleInstances) (do
errorC sourcePos $ "no instance of " <> name <> " found to match given parameters.\nInstances available:\n" <> pack (show (ONModule (Symbol name) implementation forms))
traverse_ ((`checkOptions` True) . Just . fst) forms
traverse_ ((`checkOptions` True) . Just) forms
)
-- Evaluate all of the arguments.
evaluatedArgs <- evalArgs argsExpr
-- Run the module.
let
argsMapped = argMap evaluatedArgs $ implementation sourcePos
for_ (pack <$> snd argsMapped) $ errorC sourcePos
fromMaybe (pure []) (fst argsMapped)
Just (ONModuleWithSuite _ implementation forms) -> do
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the ONModule branch, the suite argument from the AST is ignored. This changes behavior: providing a suite to a module that doesn't accept one (often a missing semicolon) will no longer error. Add an explicit check that suite is empty and emit an error when it is not.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +47
(minimum_point, maximum_point) = fromMaybe (V3 0 0 0, V3 0 0 0) maybeVs
maybeVs :: (Maybe (V3 ℝ,V3 ℝ))
maybeVs = foldl findMinMax Nothing points
where
findMinMax :: (Maybe (V3 ℝ,V3 ℝ)) -> V3 ℝ -> (Maybe (V3 ℝ,V3 ℝ))
findMinMax Nothing newV3 = Just (newV3, newV3)
findMinMax (Just (V3 minx miny minz,V3 maxx maxy maxz)) (V3 newx newy newz) = Just (V3 (min minx newx) (min miny newy) (min minz newz), V3 (max maxx newx) (max maxy newy) (max maxz newz))
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This polyhedron bounding-box computation uses lazy foldl, which can build large thunks for big meshes and lead to high memory use. Use a strict fold (e.g., foldl') for accumulating min/max.

Copilot uses AI. Check for mistakes.
cone = moduleWithoutSuite "cone" $ \_ _ -> do
polyhedron :: (Symbol, SourcePosition -> ArgParser (StateC [OVal]))
polyhedron = moduleWithoutSuite "polyhedron" $ \sourcePos -> do
example "polyhedron(points=[[0,0,0], [2,0,0], [2,2,0], [0,2,0], [1, 1, 2]], faces=[[0,1,2,3], [0,5,1], [1,5,2], [2,5,3], [3,5,4], [4,5,0]]);"
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polyhedron example references vertex index 5, but the example points list has only 5 elements (indices 0..4). This makes the example invalid and can confuse users/doc generation; update the example indices to match the points list.

Suggested change
example "polyhedron(points=[[0,0,0], [2,0,0], [2,2,0], [0,2,0], [1, 1, 2]], faces=[[0,1,2,3], [0,5,1], [1,5,2], [2,5,3], [3,5,4], [4,5,0]]);"
example "polyhedron(points=[[0,0,0], [2,0,0], [2,2,0], [0,2,0], [1,1,2]], faces=[[0,1,2,3], [0,4,1], [1,4,2], [2,4,3], [3,4,0]]);"

Copilot uses AI. Check for mistakes.
Comment on lines +300 to +303
-- A tri is constructed of three indexes into the points.
tris <- fmap concat $ mapM (trianglesFromFace sourcePos) faces
woundTris <- reWindTriangles sourcePos points tris
pure [OObj3 $ Prim.polyhedron points woundTris]
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

polyhedron defaults points/faces to empty lists and still returns Prim.polyhedron points woundTris, which will later crash in getImplicit3 with a runtime error. Validate points/faces (and that generated tris is non-empty) here and report the issue via errorC so the user gets a source-positioned error instead of a later runtime exception.

Suggested change
-- A tri is constructed of three indexes into the points.
tris <- fmap concat $ mapM (trianglesFromFace sourcePos) faces
woundTris <- reWindTriangles sourcePos points tris
pure [OObj3 $ Prim.polyhedron points woundTris]
-- Helper: conditionally run an action in the StateC monad.
let whenState cond action = if cond then action else pure ()
-- Validate that we actually have points and faces before proceeding.
whenState (null points) $
errorC sourcePos "polyhedron: no points were specified; 'points' cannot be an empty list.\n"
whenState (null faces) $
errorC sourcePos "polyhedron: no faces were specified; 'faces' cannot be an empty list.\n"
-- A tri is constructed of three indexes into the points.
tris <- fmap concat $ mapM (trianglesFromFace sourcePos) faces
-- If we could not generate any triangles, abort with a clear error instead
-- of failing later inside getImplicit3.
if null tris
then do
errorC sourcePos "polyhedron: no valid triangles could be generated from the given faces.\n"
pure []
else do
woundTris <- reWindTriangles sourcePos points tris
pure [OObj3 $ Prim.polyhedron points woundTris]

Copilot uses AI. Check for mistakes.
Comment on lines +329 to +334
(p1,p2,p3) = firstTri
(v1,v2,v3) = (genericIndex points p1,genericIndex points p2,genericIndex points p3)
-- The norm of the safe triangle.
triNorm = (v2-v1) `cross` (v3-v1)
triCentroid = centroid [v1,v2,v3]
polyCentroid = centroid points
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reWindTriangles computes safeTri using genericIndex points p1/p2/p3 without validating that face indices are in bounds. Invalid indices will throw a runtime exception without source position. Add explicit bounds checks (or reuse findTriangle which already checks indices) and surface failures through errorC sourcePos.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

Graphics/Implicit/ExtOpenScad/Eval/Statement.hs:152

  • In the ONModuleWithSuite branch, when the suite is missing you call errorC but still proceed to run the module with an empty suiteResults. Because errorC doesn’t abort, this can cascade into additional errors or unexpected output. Consider returning pure [] immediately when null vals to stop evaluation after reporting the missing-suite error.
              vals <- runSuiteCapture varlookup suite
              suiteResults <- do
                              when (null vals) (errorC sourcePos "Suite required, but none provided.")
                              pure vals
              -- Run the module.
              let
                argsMapped = argMap evaluatedArgs $ implementation sourcePos suiteResults
              for_ (pack <$> snd argsMapped) $ errorC sourcePos

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Cube ℝ3 -- rounding, size.
| Sphere ℝ -- radius
| Cylinder ℝ ℝ ℝ --
| Polyhedron [ℝ3] [(ℕ,ℕ,ℕ)] -- virtexes, triangles-by-index
CHANGELOG.md Outdated
# Version [next](https://github.com/Haskell-Things/ImplicitCAD/compare/v0.4.1.0...master) (202Y-MM-DD)

* ExtOpenScad interface changes
* Added `polyhedron()` support [#497](https://github.com/Haskell-Things/ImplicitCAD/Pull/497)
Comment on lines +221 to +224
arbitrary = do
n <- getPositive <$> arbitrary
return n

Comment on lines +60 to +62
-- FIXME: >=BASE-4.21: replace this with compareLength once debian stable ships 4.21.
outOfRange :: ℕ -> Bool
outOfRange v = v < 0 || length vertices <= fromℕ v
deriving Eq

-- FIXME: Make these indices correct by construction?
-- | Reconstitune a Triangle from a Tri, and our points array.
Comment on lines +75 to +76
getImplicit3 _ (Polyhedron [] _) = error "Asked to find distance to an empty polygon. No points."
getImplicit3 _ (Polyhedron _ []) = error "Asked to find distance to an empty polygon. No tris."
Comment on lines +295 to +306
polyhedron :: (Symbol, SourcePosition -> ArgParser (StateC [OVal]))
polyhedron = moduleWithoutSuite "polyhedron" $ \sourcePos -> do
example "polyhedron(points=[[0,0,0], [2,0,0], [2,2,0], [0,2,0], [1, 1, 2]], faces=[[0,1,2,3], [0,4,1], [1,4,2], [2,4,3], [3,4,0]]);"
-- arguments
points :: [ℝ3] <- argument "points" `doc` "list of points to construct faces from"
faces :: [[ℕ]] <- argument "faces" `doc` "list of sets of indices into points, used to create faces on the polyhedron."
pure $ do
-- A tri is constructed of three indexes into the points.
tris <- fmap concat $ mapM (trianglesFromFace sourcePos) faces
woundTris <- reWindTriangles sourcePos points tris
pure [OObj3 $ Prim.polyhedron points woundTris]
where
else negate $ res
where
unsignedDistanceAndTriangleClosestTo point = minimumBy (\((_,a),_) ((_,b),_) -> a `compare` b) $ featDistTriangles point
featDistTriangles point = (\a -> (distancePointToTriangle point (findTriangle points a), a)) <$> tris
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +13 to +14
import qualified Data.Either as Either (either)

Comment on lines +60 to +62
-- FIXME: >=BASE-4.21: replace this with compareLength once debian stable ships 4.21.
outOfRange :: ℕ -> Bool
outOfRange v = v < 0 || length vertices <= fromℕ v
Comment on lines +302 to +305
-- A tri is constructed of three indexes into the points.
tris <- fmap concat $ mapM (trianglesFromFace sourcePos) faces
woundTris <- reWindTriangles sourcePos points tris
pure [OObj3 $ Prim.polyhedron points woundTris]
Comment on lines +324 to +336
reWindTriangles sourcePos points (firstTri:moreTris) = windTriangles [safeTri] (fromList moreTris)
where
-- The first triangle, flipped based on comparing two centroids.
safeTri
| (triCentroid - polyCentroid) `dot` triNorm < 0 = flipTri firstTri
| otherwise = firstTri
where
(p1,p2,p3) = firstTri
(v1,v2,v3) = (genericIndex points p1,genericIndex points p2,genericIndex points p3)
-- The norm of the safe triangle.
triNorm = (v2-v1) `cross` (v3-v1)
triCentroid = centroid [v1,v2,v3]
polyCentroid = centroid points
Comment on lines +136 to +142
-- | A polyhedron
polyhedron
:: [ℝ3] -- ^ Points
-> [(ℕ,ℕ,ℕ)] -- ^ triangles, resolved through indexing Points
-> SymbolicObj3 -- ^ Resulting polyhedron
polyhedron = Polyhedron

@julialongtin julialongtin merged commit 0f50247 into master Mar 19, 2026
6 checks passed
@julialongtin julialongtin deleted the add_polyhedron branch March 19, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants