Skip to content

SolidCraftBuilderSmokeTest.kt

src/jvmMain/kotlin/afield/geometry/solid/solidcraft/SolidCraftBuilderSmokeTest.kt · 194 lines

package afield.geometry.solid.solidcraft

import afield.geometry.path.FLine
import afield.geometry.mesh.weld
import afield.geometry.mesh.genus
import afield.geometry.mesh.isWatertight
import org.json.JSONObject
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin

object SolidCraftBuilderSmokeTest

Run with ./gradlew smokeSolidCraft: builds one design per construction feature and checks its geometry.

object SolidCraftBuilderSmokeTest {
    private val report = StringBuilder()

    private fun body(design: SolidCraftDesign, index: Int = 0): JSONObject = design.summary().getJSONArray("bodies").getJSONObject(index)

    private fun case(label: String, builder: SolidCraftBuilder, check: (SolidCraftDesign) -> Unit) {
        try {
            builder.open().use { design ->
                check(design)
                val mesh = design.mesh(0, 0.1)
                check(mesh.indices.isNotEmpty()) { "$label: empty mesh" }
                report.append("  $label: ${mesh.faceIds.size} triangles\n")
            }
        } catch (t: Throwable) {
            throw IllegalStateException("$label failed: ${t.message}\n${builder.json()}", t)
        }
    }

The enclosed volume of the tessellation, independent of SolidCraft's own measure.

    private fun meshVolume(design: SolidCraftDesign): Double {
        val m = design.mesh(0, 0.01)
        var v = 0.0
        for (t in m.indices.indices step 3) {
            val (a, b, c) = Triple(3 * m.indices[t], 3 * m.indices[t + 1], 3 * m.indices[t + 2])
            val p = m.positions
            v += p[a] * (p[b + 1] * p[c + 2] - p[b + 2] * p[c + 1]) - p[a + 1] * (p[b] * p[c + 2] - p[b + 2] * p[c]) + p[a + 2] * (p[b] * p[c + 1] - p[b + 1] * p[c])
        }
        return v / 6.0
    }

    private fun near(label: String, actual: Double, expected: Double, tolerance: Double) =
        check(abs(actual - expected) <= tolerance * abs(expected)) { "$label: $actual, expected $expected" }

@JvmStatic fun main(args: Array<String>)

body — 147 lines, from line 47
    @JvmStatic fun main(args: Array<String>) {
        check(SolidCraftLibrary.isAvailable) { SolidCraftLibrary.describe() }

        // Extrude, with a parameter that stays editable after construction.
        case("extrude", solidCraft { param("h", "10 mm"); extrude(sketch { rectangle(0.0, 0.0, 20.0, 10.0) }, "h") }) { d ->
            near("extrude volume", body(d).getDouble("volume"), 2000.0, 1e-6)
            d.setParameter("h", "25 mm")
            near("edited extrude volume", body(d).getDouble("volume"), 5000.0, 1e-6)
            check(String(d.exportStep(), Charsets.UTF_8).contains("ISO-10303-21"))
            SolidCraftDesign.open(d.saveDesign()).use { re -> near("reopened", body(re).getDouble("volume"), 5000.0, 1e-6) }
        }

        // Revolve: a 5 × 10 rectangle at radius 10..15 about the sketch y axis makes a ring.
        case("revolve", solidCraft { revolve(sketch("XY") { rectangle(10.0, 0.0, 15.0, 10.0) }, "y") }) { d ->
            near("ring volume", body(d).getDouble("volume"), PI * (15.0 * 15.0 - 10.0 * 10.0) * 10.0, 1e-3)
        }

        // Sweep along a straight path, plain and twisted: twist keeps the area, so the volume.
        for (twist in listOf(null, "90 deg")) {
            case("sweep twist=$twist", solidCraft {
                val section = sketch("XY") { rectangle(-4.0, -2.0, 4.0, 2.0) }
                val path = sketch("XZ") { line(0.0, 0.0, 0.0, 60.0) }
                sweep(section, path, twist = twist)
            }) { d -> near("swept volume", body(d).getDouble("volume"), 8.0 * 4.0 * 60.0, 2e-2) }
        }

        // Sweep along a curved spline path. The path leaves the profile about 27° off square, and
        // the sweep keeps the profile at that angle to the path, so the section square to the path
        // is the disc's area times cos(27°): the volume is that times the length, which the side
        // area gives (side = 2πr·length; the two caps are the drawn discs).
        case("sweep spline path", solidCraft {
            val section = sketch("XY") { circle(0.0, 0.0, 2.0) }
            val path = sketch("XZ") { spline(0.0, 0.0, 10.0, 20.0, 0.0, 40.0, -10.0, 60.0) }
            sweep(section, path)
        }) { d ->
            val b = body(d)
            val length = (b.getDouble("area") - 2 * PI * 4.0) / (2 * PI * 2.0)
            check(length > 60.0) { "spline path length $length" }
            val squareSection = b.getDouble("volume") / length / (PI * 4.0)
            check(squareSection in 0.8..0.95) { "section/disc $squareSection, mesh volume ${meshVolume(d)}" }
        }

        // Sweep with a guide rail: the rail widens from 4 to 12 away from the path, so the
        // section's far side follows it and the body is bigger than the plain sweep.
        case("sweep with rail", solidCraft {
            val section = sketch("XY") { rectangle(-4.0, -4.0, 4.0, 4.0) }
            val path = sketch("XZ") { line(0.0, 0.0, 0.0, 60.0) }
            val rail = sketch("XZ") { line(4.0, 0.0, 12.0, 60.0) }
            sweep(section, path, rails = listOf(rail))
        }) { d ->
            val v = body(d).getDouble("volume")
            check(v > 8.0 * 8.0 * 60.0 * 1.2) { "rail did not widen the sweep: $v" }
        }

        // Sweep along a 3D wire made from a Field FLine: a trefoil knot stopped one step short of its
        // start. Since v0.30 a closed path is sewn shut, which needs the profile to come back unturned,
        // and a knot turns it (about 127°); the open knot is a capped tube. The profile is drawn on the
        // plane square to the knot's start, so the tube is true: volume = πr² × length.
        val knot = FLine().moveTo(0f, -8f, 0f).also { l ->
            for (i in 1..239) {
                val t = i / 240.0 * 2 * PI
                l.lineTo(8 * (sin(t) + 2 * sin(2 * t)), 8 * (cos(t) - 2 * cos(2 * t)), -8 * sin(3 * t))
            }
        }
        val knotLength = knot.cursor().lengthD().toDouble()
        case("sweep along FLine trefoil", solidCraft {
            val section = sketch(planeSquareTo(knot)) { circle(0.0, 0.0, 2.5) }
            sweep(section, sketch("XY") { wire(knot, spacing = 0.5f) })
        }) { d -> near("trefoil tube volume", body(d).getDouble("volume"), PI * 2.5 * 2.5 * knotLength, 3e-2) }

        // A sweep caps both ends even when its path closes, so a ring is two half sweeps joined:
        // the join removes the touching caps, leaving exactly a torus's area, genus 1. (The same join
        // on the trefoil's freeform halves fails in SolidCraft v0.15.0 after minutes: "no result".)
        case("ring from two joined half sweeps", solidCraft {
            sweep(sketch("XZ") { circle(20.0, 0.0, 2.5) }, sketch("XY") { arc(0.0, 0.0, 20.0, 0.0, -20.0, 0.0) })
            sweep(sketch("XZ") { circle(-20.0, 0.0, 2.5) }, sketch("XY") { arc(0.0, 0.0, -20.0, 0.0, 20.0, 0.0) }, operation = SolidCraftOperation.JOIN)
        }) { d ->
            near("torus area", body(d).getDouble("area"), 4 * PI * PI * 20.0 * 2.5, 1e-3)
            val m = d.mesh(0, 0.05).toTriMesh().weld()
            check(m.isWatertight() && m.genus() == 1) { "ring is not a closed genus-1 solid: genus ${m.genus()}" }
        }

        case("loft", solidCraft {
            val a = sketch("XY") { rectangle(-10.0, -10.0, 10.0, 10.0) }
            val b = sketch(offsetPlane("XY", "30")) { circle(0.0, 0.0, 6.0) }
            loft(listOf(a, b))
        }) { d -> val v = body(d).getDouble("volume"); check(v > PI * 36.0 * 30.0 && v < 400.0 * 30.0) { "loft volume $v" } }

        case("pipe", solidCraft { pipe(sketch("XY") { line(0.0, 0.0, 50.0, 0.0) }, diameter = "6", wall = "1") }) { d ->
            near("pipe volume", body(d).getDouble("volume"), PI * (9.0 - 4.0) * 50.0, 1e-2)
        }

        // Surfaces: open sheet bodies.
        case("surface sweep", solidCraft {
            val profile = sketch("XY") { arc(0.0, 0.0, 10.0, 0.0, -10.0, 0.0) }
            val path = sketch("XZ") { line(0.0, 0.0, 0.0, 40.0) }
            surfaceSweep(profile, path)
        }) { d -> near("half-cylinder area", body(d).getDouble("area"), PI * 10.0 * 40.0, 2e-2) }

        case("surface sweep with rail", solidCraft {
            val profile = sketch("XY") { line(-10.0, 0.0, 10.0, 0.0) }
            val path = sketch("XZ") { line(0.0, 0.0, 0.0, 40.0) }
            val rail = sketch("XZ") { spline(10.0, 0.0, 16.0, 20.0, 10.0, 40.0) }
            surfaceSweep(profile, path, rails = listOf(rail))
        }) { d ->
            val maxX = body(d).getJSONObject("bounds").getJSONArray("max").getDouble(0)
            check(maxX > 14.0) { "surface did not follow the rail out: max x $maxX, ${d.summary()}" }
        }

        case("surface loft G1", solidCraft {
            val a = sketch("XY") { spline(-20.0, 0.0, 0.0, 6.0, 20.0, 0.0) }
            val b = sketch(offsetPlane("XY", "20")) { spline(-20.0, 0.0, 0.0, -6.0, 20.0, 0.0) }
            val c = sketch(offsetPlane("XY", "40")) { spline(-20.0, 0.0, 0.0, 6.0, 20.0, 0.0) }
            surfaceLoft(listOf(a, b, c))
        }) { d -> check(body(d).getDouble("area") > 40.0 * 40.0) }

        case("surface revolve", solidCraft { surfaceRevolve(sketch("XY") { spline(10.0, 0.0, 14.0, 10.0, 8.0, 20.0) }, "y") }) { d ->
            check(body(d).getDouble("area") > 2 * PI * 8.0 * 20.0)
        }

        // A boundary patch bridging two curves, with a crown lifting its middle.
        case("surface boundary", solidCraft {
            val s = sketch("XY") {
                spline(0.0, 0.0, 10.0, -3.0, 20.0, 0.0)
                spline(0.0, 20.0, 10.0, 23.0, 20.0, 20.0)
            }
            surfaceBoundary(s, crown = 0.5)
        }) { d -> check(body(d).getDouble("area") > 400.0) { "boundary patch ${d.summary()}" } }

        // Three curves in loop order (each ends where the next begins) give a flat-sided patch;
        // a crown is refused there.
        case("surface boundary of three curves", solidCraft {
            val s = sketch("XY") {
                spline(0.0, 0.0, 10.0, -3.0, 20.0, 0.0)
                spline(20.0, 0.0, 13.0, 10.0, 10.0, 17.0)
                spline(10.0, 17.0, 3.0, 10.0, 0.0, 0.0)
            }
            surfaceBoundary(s)
        }) { d -> check(body(d).getDouble("area") > 150.0) { "three-curve patch ${d.summary()}" } }

        // A failing feature reports SolidCraft's reason instead of producing a model.
        val bad = runCatching { solidCraft { extrude(sketch { line(0.0, 0.0, 10.0, 0.0) }, "5") }.open() }.exceptionOrNull()
        check(bad != null) { "an extrude of an open curve unexpectedly evaluated" }

        print("SolidCraft builder smoke passed:\n$report")
    }
}