package core
import (
"runtime"
"sync"
"github.com/siherrmann/slicer/model"
)
// GenerateFullSTLPath erzeugt einen zusammenhängenden Pfad für das gesamte Modell
func GenerateFullSTLPath(bm *model.BaseModel, config model.SliceConfig) []model.ContinuousPath {
var fullPaths []model.ContinuousPath
currentPos := config.StartPosition
// Helper function to append paths and auto-generate travel moves between them
appendPathsWithTravel := func(pathsToAdd []model.ContinuousPath) {
for _, path := range pathsToAdd {
if len(path.Segments) == 0 {
continue
}
pathStart := path.Segments[0].Start
if currentPos.Distance(pathStart) > 0.01 {
fullPaths = append(fullPaths, model.ContinuousPath{
Segments: []model.PathSegment{{Start: currentPos, End: pathStart, IsTravel: true}},
PathType: model.PathTravel,
LayerIndex: path.LayerIndex,
})
}
fullPaths = append(fullPaths, path)
currentPos = path.Segments[len(path.Segments)-1].End
}
}
// -1. Generate Raft if needed
if config.RaftLayers > 0 && len(bm.Slices) > 0 {
raftPaths, _ := GenerateRaft(bm.Slices[0].Polygons, config)
appendPathsWithTravel(raftPaths)
}
if len(bm.Slices) > 0 {
skirtPaths := GenerateSkirt(bm.Slices[0].Polygons, config)
appendPathsWithTravel(skirtPaths)
}
// Pre-compute support paths if enabled
var supportPaths map[int][]model.ContinuousPath
if config.SupportType != model.SupportNone {
supportPaths = GenerateSupportPaths(bm, config)
}
// Prepare a slice to catch layer results in order
results := make([][]model.ContinuousPath, len(bm.Slices))
var wg sync.WaitGroup
// Determine number of workers
numWorkers := runtime.NumCPU()
semaphore := make(chan struct{}, numWorkers)
// 1. Calculate layer paths in parallel
for i := range bm.Slices {
wg.Add(1)
go func(idx int) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
slice := bm.Slices[idx]
var aboveSlice, belowSlice *model.Slice
if idx+1 < len(bm.Slices) {
aboveSlice = bm.Slices[idx+1]
}
if idx-1 >= 0 {
belowSlice = bm.Slices[idx-1]
}
results[idx] = GenerateLayerPath(slice.Polygons, aboveSlice, belowSlice, config, bm.Bounds, idx)
}(i)
}
wg.Wait()
// 2. Combine results with travel moves and Z-hops sequentially to maintain order and connectivity
for i, layerPaths := range results {
if len(layerPaths) == 0 {
continue
}
// Add each path with travel moves between them
appendPathsWithTravel(layerPaths)
// 5. Kollisionsvermeidung: Z-Hop (Optional aber empfohlen)
if i < len(bm.Slices)-1 {
nextZ := bm.Slices[i+1].Z
zHopPos := model.Vector3{X: currentPos.X, Y: currentPos.Y, Z: nextZ + config.LayerHeight}
zHopSegments := []model.PathSegment{
{Start: currentPos, End: zHopPos, IsTravel: true},
}
fullPaths = append(fullPaths, model.ContinuousPath{
Segments: zHopSegments,
PathType: model.PathTravel,
LayerIndex: i,
})
currentPos = zHopPos
}
// 6. Add support paths for this layer if any
if paths, ok := supportPaths[i]; ok {
appendPathsWithTravel(paths)
}
}
// 6. Finaler Travel Move zur Endkoordinate
if !currentPos.Equals(config.EndPosition) {
fullPaths = append(fullPaths, model.ContinuousPath{
Segments: []model.PathSegment{
{Start: currentPos, End: config.EndPosition, IsTravel: true},
},
PathType: model.PathTravel,
LayerIndex: len(bm.Slices) - 1,
})
}
return fullPaths
}
// CleanFullPaths combines all paths and simplifies them using the Ramer-Douglas-Peucker algorithm
// epsilon controls the simplification tolerance - larger values = more aggressive simplification
// typical values: 0.01 to 0.1 mm
func CleanFullPaths(paths []model.ContinuousPath, epsilon float64) model.ContinuousPath {
if len(paths) == 0 {
return model.ContinuousPath{}
}
var allSegments []model.PathSegment
for _, path := range paths {
allSegments = append(allSegments, path.Segments...)
}
if len(allSegments) == 0 {
return model.ContinuousPath{}
}
var cleanedSegments []model.PathSegment
currentSection := []model.Vector3{allSegments[0].Start}
currentIsTravel := allSegments[0].IsTravel
flushSection := func(section []model.Vector3, isTravel bool) {
if len(section) < 2 {
return
}
// Simplify
secEpsilon := epsilon
if isTravel {
secEpsilon = epsilon * 2
}
simplified := ramerDouglasPeucker(section, secEpsilon)
for i := 0; i < len(simplified)-1; i++ {
cleanedSegments = append(cleanedSegments, model.PathSegment{
Start: simplified[i],
End: simplified[i+1],
IsTravel: isTravel,
})
}
}
for _, seg := range allSegments {
if seg.IsTravel != currentIsTravel {
flushSection(currentSection, currentIsTravel)
currentSection = []model.Vector3{seg.Start}
currentIsTravel = seg.IsTravel
}
currentSection = append(currentSection, seg.End)
}
flushSection(currentSection, currentIsTravel)
return model.ContinuousPath{
Segments: cleanedSegments,
PathType: model.PathExtrusion,
LayerIndex: paths[0].LayerIndex, // preserve layer index from the first path
}
}
// ramerDouglasPeucker implements the Ramer-Douglas-Peucker algorithm for polyline simplification
func ramerDouglasPeucker(points []model.Vector3, epsilon float64) []model.Vector3 {
if len(points) <= 2 {
return points
}
// Find the point with maximum distance from the line segment
maxDist := 0.0
maxIndex := 0
start := points[0]
end := points[len(points)-1]
for i := 1; i < len(points)-1; i++ {
dist := points[i].PerpendicularDistanceToLine(start, end)
if dist > maxDist {
maxDist = dist
maxIndex = i
}
}
// If max distance is greater than epsilon, recursively simplify
if maxDist > epsilon {
// Recursive call on both segments
rec1 := ramerDouglasPeucker(points[:maxIndex+1], epsilon)
rec2 := ramerDouglasPeucker(points[maxIndex:], epsilon)
// Build result (remove duplicate point at connection)
result := make([]model.Vector3, len(rec1)+len(rec2)-1)
copy(result, rec1)
copy(result[len(rec1):], rec2[1:])
return result
}
// If max distance is less than epsilon, remove all points between start and end
return []model.Vector3{start, end}
}
package core
import (
"fmt"
"math"
"strings"
"time"
"github.com/siherrmann/slicer/model"
)
// GenerateGCode converts print paths into G-code for 3D printing.
// It handles: extrusion calculation, retraction, fan control, speed management,
// Z-hop, and layer-specific overrides (first layer speed, min layer time).
func GenerateGCode(paths []model.ContinuousPath, config model.SliceConfig) string {
var sb strings.Builder
// Header comments
sb.WriteString("; Generated by Slicer\n")
sb.WriteString(fmt.Sprintf("; Date: %s\n", time.Now().Format("2006-01-02 15:04:05")))
sb.WriteString(fmt.Sprintf("; Layer Height: %.2f mm\n", config.LayerHeight))
sb.WriteString(fmt.Sprintf("; First Layer: %.2f mm\n", config.FirstLayer))
sb.WriteString(fmt.Sprintf("; Nozzle Diameter: %.2f mm\n", config.NozzleDiameter))
sb.WriteString(fmt.Sprintf("; Line Width: %.2f mm\n", config.LineWidth))
sb.WriteString(fmt.Sprintf("; Infill Density: %.0f%%\n", config.InfillDensity*100))
sb.WriteString(fmt.Sprintf("; Flow Multiplier: %.2f\n", config.FlowMultiplier))
sb.WriteString("\n")
// Start G-code
sb.WriteString(config.StartGCode)
sb.WriteString("\n")
// State tracking
var (
totalE float64 // Total extrusion distance
currentZ float64
currentLayer int = -1
retracted bool
fanOn bool
lastSpeed float64
lastX, lastY float64
absoluteE bool = true // Use absolute extrusion mode
)
// Set absolute extrusion mode
sb.WriteString("M82 ; Absolute extrusion\n")
sb.WriteString("G90 ; Absolute positioning\n\n")
for _, path := range paths {
for _, seg := range path.Segments {
// Detect layer change by checking Z
segZ := seg.Start.Z
if segZ != currentZ && segZ > 0 {
currentZ = segZ
currentLayer++
sb.WriteString(fmt.Sprintf("\n; Layer %d, Z = %.3f\n", currentLayer, currentZ))
// Fan control based on FanOnLayer
if !fanOn && currentLayer >= config.FanOnLayer {
sb.WriteString(fmt.Sprintf("M106 S%d ; Fan on\n", config.FanSpeed))
fanOn = true
} else if currentLayer < config.FanOnLayer && fanOn {
sb.WriteString("M107 ; Fan off\n")
fanOn = false
}
// Move to layer Z
sb.WriteString(fmt.Sprintf("G0 Z%.3f F%s\n", currentZ, speedToF(config.TravelSpeed)))
}
if seg.IsTravel {
// Retract before travel if not already retracted
if !retracted && config.RetractionDist > 0 {
totalE -= config.RetractionDist
sb.WriteString(fmt.Sprintf("G1 E%.5f F%s ; Retract\n", totalE, speedToF(config.RetractionSpeed)))
// Z-hop
if config.RetractionZHop > 0 {
sb.WriteString(fmt.Sprintf("G0 Z%.3f ; Z-hop\n", currentZ+config.RetractionZHop))
}
retracted = true
}
// Travel move
sb.WriteString(fmt.Sprintf("G0 X%.3f Y%.3f F%s\n",
seg.End.X, seg.End.Y, speedToF(config.TravelSpeed)))
lastX = seg.End.X
lastY = seg.End.Y
} else {
// Un-retract before extrusion
if retracted {
// Remove Z-hop
if config.RetractionZHop > 0 {
sb.WriteString(fmt.Sprintf("G0 Z%.3f\n", currentZ))
}
// Prime (un-retract + extra prime)
totalE += config.RetractionDist + config.RetractionPrime
sb.WriteString(fmt.Sprintf("G1 E%.5f F%s ; Un-retract\n", totalE, speedToF(config.RetractionSpeed)))
retracted = false
}
// Calculate extrusion amount
segLength := segmentLength(seg)
layerHeight := config.LayerHeight
if currentLayer == 0 {
layerHeight = config.FirstLayer
}
extrusionAmount := calculateExtrusion(segLength, config.LineWidth, layerHeight,
config.NozzleDiameter, config.FlowMultiplier)
totalE += extrusionAmount
// Determine speed for this segment
speed := getSegmentSpeed(seg, config, currentLayer)
if speed != lastSpeed {
lastSpeed = speed
}
// Extrusion move
if absoluteE {
sb.WriteString(fmt.Sprintf("G1 X%.3f Y%.3f E%.5f F%s\n",
seg.End.X, seg.End.Y, totalE, speedToF(speed)))
}
lastX = seg.End.X
lastY = seg.End.Y
}
}
}
// Suppress unused variable warnings
_ = lastX
_ = lastY
sb.WriteString("\n")
// End G-code
sb.WriteString(config.EndGCode)
return sb.String()
}
// calculateExtrusion computes the extrusion distance for a segment.
// Uses volumetric calculation: volume = length * width * height
// Then converts to filament length: E = volume / (π * (filament_d/2)²)
// Standard filament diameter is 1.75mm.
func calculateExtrusion(segLength, lineWidth, layerHeight, nozzleDiameter, flowMultiplier float64) float64 {
filamentDiameter := 1.75 // mm — standard filament
filamentArea := math.Pi * (filamentDiameter / 2.0) * (filamentDiameter / 2.0)
// Cross-section area of the extruded line (approximate as rectangle)
crossSection := lineWidth * layerHeight
// Volume of material needed
volume := crossSection * segLength
// Convert to filament feed length
e := (volume / filamentArea) * flowMultiplier
return e
}
// getSegmentSpeed determines the appropriate speed for a segment
// based on its category and the current layer.
func getSegmentSpeed(seg model.PathSegment, config model.SliceConfig, layerIndex int) float64 {
// Use per-segment speed if explicitly set
if seg.Speed > 0 {
speed := seg.Speed
// First layer override
if layerIndex == 0 && config.FirstLayerSpeed > 0 {
speed = math.Min(speed, config.FirstLayerSpeed)
}
return math.Max(speed, config.MinSpeed)
}
// Determine speed from category
var speed float64
switch seg.Category {
case model.CategoryOuterWall:
speed = config.OuterShellSpeed
case model.CategoryInnerWall:
speed = config.WallSpeed
case model.CategoryInfill:
speed = config.InfillSpeed
case model.CategorySolidInfill:
speed = config.InfillSpeed * 0.8 // Slightly slower for solid infill
case model.CategorySupport:
speed = config.SupportSpeed
case model.CategorySkirt, model.CategoryBrim:
speed = config.WallSpeed
default:
speed = config.InfillSpeed
}
// First layer override
if layerIndex == 0 && config.FirstLayerSpeed > 0 {
speed = math.Min(speed, config.FirstLayerSpeed)
}
return math.Max(speed, config.MinSpeed)
}
// segmentLength calculates the XY length of a path segment (ignoring Z)
func segmentLength(seg model.PathSegment) float64 {
dx := seg.End.X - seg.Start.X
dy := seg.End.Y - seg.Start.Y
return math.Sqrt(dx*dx + dy*dy)
}
// speedToF converts mm/s to F-value (mm/min) as a formatted string
func speedToF(mmPerSec float64) string {
return fmt.Sprintf("%.0f", mmPerSec*60.0)
}
package core
import (
"math"
"github.com/siherrmann/slicer/model"
)
func GenerateInfill(bounds model.BoundingBox, shell *model.Polygon, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
var infillPattern model.ContinuousPath
switch params.InfillType {
case model.InfillGrid:
infillPattern = GenerateGridInfill(bounds, params, layerIndex, z)
case model.InfillTriHexagon:
infillPattern = GenerateTriHexagonInfill(bounds, params, layerIndex, z)
case model.InfillCross:
infillPattern = GenerateCrossInfill(bounds, params, layerIndex, z)
case model.InfillHoneycombContinuous:
infillPattern = GenerateHoneycombInfill(bounds, params, layerIndex, z)
case model.InfillGyroid:
infillPattern = GenerateGyroidInfill(bounds, params, layerIndex, z)
// Full infill patterns for top/bottom layers (100% coverage with overlap)
case model.InfillLineFull:
infillPattern = GenerateLineInfillFull(bounds, params, layerIndex, z, 0)
case model.InfillRectilinearFull:
infillPattern = GenerateRectilinearInfillFull(bounds, params, layerIndex, z)
case model.InfillConcentricFull:
infillPattern = GenerateConcentricInfillFull(shell, params, layerIndex)
default:
infillPattern = GenerateLineInfill(bounds, params, layerIndex, z)
}
return infillPattern
}
// InfillGenerator is a function type that generates uncut infill patterns
// Patterns cover the bounding box plus margin to ensure all lines get cut
// Uses model bounds (not shell bounds) to ensure consistent alignment across layers
type InfillGenerator func(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath
// GenerateLineInfill creates a line infill pattern at the specified angle
// It shifts the lines by half spacing on alternate layers so they do not cross in the same layer
// and bonds with the previous layer correctly without creating a grid.
func GenerateLineInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
spacing := params.LineWidth / math.Max(0.01, params.InfillDensity)
angleDeg := params.InfillAngle
angleRad := angleDeg * math.Pi / 180.0
var segments []model.PathSegment
center := model.Vector3{
X: (bounds.MinX + bounds.MaxX) / 2,
Y: (bounds.MinY + bounds.MaxY) / 2,
Z: z,
}
// Calculate required margin to cover bounding box when rotated
width := bounds.MaxX - bounds.MinX
height := bounds.MaxY - bounds.MinY
radius := math.Sqrt(width*width+height*height)/2.0 + spacing*2.0
// Shift half a spacing on alternate layers to bridge the lines
offset := 0.0
if layerIndex%2 == 1 {
offset = spacing / 2.0
}
lineIndex := 0
for y := center.Y - radius + offset; y <= center.Y+radius; y += spacing {
var start, end model.Vector3
// Zig-zag to minimize travel moves
if lineIndex%2 == 0 {
start = model.Vector3{X: center.X - radius, Y: y, Z: z}
end = model.Vector3{X: center.X + radius, Y: y, Z: z}
} else {
start = model.Vector3{X: center.X + radius, Y: y, Z: z}
end = model.Vector3{X: center.X - radius, Y: y, Z: z}
}
if angleRad != 0 {
start = start.RotateAroundPoint(center, angleRad)
end = end.RotateAroundPoint(center, angleRad)
}
segments = append(segments, model.PathSegment{
Start: start,
End: end,
IsTravel: false,
Category: model.CategoryInfill,
})
lineIndex++
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateGridInfill creates a grid infill pattern (lines at 0° and 90°)
func GenerateGridInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
spacing := params.LineWidth / math.Max(0.01, params.InfillDensity)
margin := spacing * 2.0
var segments []model.PathSegment
// Horizontal lines
lineIndex := 0
for y := bounds.MinY - margin; y <= bounds.MaxY+margin; y += spacing {
var start, end model.Vector3
if lineIndex%2 == 0 {
start = model.Vector3{X: bounds.MinX - margin, Y: y, Z: z}
end = model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z}
} else {
start = model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z}
end = model.Vector3{X: bounds.MinX - margin, Y: y, Z: z}
}
segments = append(segments, model.PathSegment{
Start: start,
End: end,
IsTravel: false,
Category: model.CategoryInfill,
})
lineIndex++
}
// Vertical lines
lineIndex = 0
for x := bounds.MinX - margin; x <= bounds.MaxX+margin; x += spacing {
var start, end model.Vector3
if lineIndex%2 == 0 {
start = model.Vector3{X: x, Y: bounds.MinY - margin, Z: z}
end = model.Vector3{X: x, Y: bounds.MaxY + margin, Z: z}
} else {
start = model.Vector3{X: x, Y: bounds.MaxY + margin, Z: z}
end = model.Vector3{X: x, Y: bounds.MinY - margin, Z: z}
}
segments = append(segments, model.PathSegment{
Start: start,
End: end,
IsTravel: false,
Category: model.CategoryInfill,
})
lineIndex++
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateTriHexagonInfill creates a tri-hexagon infill pattern (lines at 0°, 60°, 120°)
func GenerateTriHexagonInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
spacing := params.LineWidth / math.Max(0.01, params.InfillDensity)
margin := spacing * 2.0
var segments []model.PathSegment
// Three angles: 0°, 60°, 120°
angles := []float64{0, math.Pi / 3.0, 2.0 * math.Pi / 3.0}
for _, angle := range angles {
// Rotate bounds to create lines at this angle
// For simplicity, create lines in original orientation and rotate them
for y := bounds.MinY - margin; y <= bounds.MaxY+margin; y += spacing {
start := model.Vector3{X: bounds.MinX - margin, Y: y, Z: z}.Rotate(angle)
end := model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z}.Rotate(angle)
segments = append(segments, model.PathSegment{
Start: start,
End: end,
IsTravel: false,
})
}
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateHoneycombInfill creates a continuous honeycomb zigzag pattern
// Pattern: \_/¯\_/ (angled down, horizontal, angled up, horizontal, repeat)
// Every second row is mirrored so horizontal parts touch each other
func GenerateHoneycombInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
spacing := params.LineWidth / math.Max(0.01, params.InfillDensity)
// Honeycomb cell dimensions
cellWidth := spacing * 2.0
cellHeight := spacing * math.Sqrt(3) // Height of hexagon
margin := cellWidth * 2.0
var segments []model.PathSegment
// Create rows of zigzag pattern
rowNum := 0
// Increase row spacing slightly to prevent overlap (add linewidth to spacing)
adjustedSpacing := (cellHeight / 2.0) + (params.LineWidth / 2.0)
for y := bounds.MinY - margin; y < bounds.MaxY+margin; y += adjustedSpacing {
// Offset alternating rows by half cell width for honeycomb interlocking
xStart := bounds.MinX - margin
if rowNum%2 == 1 {
xStart += cellWidth
}
var points []model.Vector3
// Determine print direction for this row
printLeftToRight := rowNum%2 == 0
// Build zigzag pattern
x := xStart
yPos := y
down := true
for x < bounds.MaxX+margin+cellWidth*2 {
if down {
// Angled down segment
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
yPos += cellHeight / 2.0
x += cellWidth / 2.0
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
// Horizontal segment
x += cellWidth / 2.0
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
} else {
// Angled up segment
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
yPos -= cellHeight / 2.0
x += cellWidth / 2.0
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
// Horizontal segment
x += cellWidth / 2.0
points = append(points, model.Vector3{X: x, Y: yPos, Z: z})
}
down = !down
}
// Reverse points if printing right to left
if !printLeftToRight {
for i := 0; i < len(points)/2; i++ {
points[i], points[len(points)-1-i] = points[len(points)-1-i], points[i]
}
}
// Convert points to segments
for i := 0; i < len(points)-1; i++ {
segments = append(segments, model.PathSegment{
Start: points[i],
End: points[i+1],
IsTravel: false,
})
}
rowNum++
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateCrossInfill creates a cross infill pattern
func GenerateCrossInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
var segments []model.PathSegment
spacing := params.LineWidth / math.Max(0.01, params.InfillDensity)
margin := spacing * 2.0
for y := bounds.MinY - margin; y < bounds.MaxY+margin; y += spacing {
for x := bounds.MinX - margin; x < bounds.MaxX+margin; x += spacing {
// Erzeuge ein "X" oder "+" Muster innerhalb der Zelle
points := []model.Vector3{
{X: x, Y: y, Z: z},
{X: x + spacing, Y: y + spacing, Z: z},
{X: x + spacing, Y: y, Z: z},
{X: x, Y: y + spacing, Z: z},
}
// Add both cross segments - cutting will handle boundaries
segments = append(segments, model.PathSegment{
Start: points[0],
End: points[1],
IsTravel: false,
})
segments = append(segments, model.PathSegment{
Start: points[2],
End: points[3],
IsTravel: false,
})
}
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateGyroidInfill creates a gyroid infill pattern based on the mathematical gyroid surface.
// The gyroid surface is defined by: sin(x)cos(y) + sin(y)cos(z) + sin(z)cos(x) = 0
// Implementation based on PrusaSlicer's FillGyroid algorithm.
// At each layer height z, the cross-section produces smooth sinusoidal waves that
// transition between horizontal and vertical orientations as z changes.
func GenerateGyroidInfill(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
densityAdjusted := math.Max(0.01, params.InfillDensity)
lineSpacing := params.LineWidth / densityAdjusted
// Scale factor: the gyroid math works in a normalized coordinate system.
// One full period is 2*PI in the normalized system.
scaleFactor := lineSpacing / densityAdjusted
// Tolerance for adaptive resolution (in model space)
tolerance := math.Min(lineSpacing/2.0, 0.1) / scaleFactor
// Compute z in normalized coordinates
zNorm := z / scaleFactor
zSin := math.Sin(zNorm)
zCos := math.Cos(zNorm)
// Determine if waves are primarily vertical or horizontal at this z
vertical := math.Abs(zSin) <= math.Abs(zCos)
// Compute width and height in normalized coordinates
margin := lineSpacing * 2.0
modelWidth := (bounds.MaxX - bounds.MinX) + 2*margin
modelHeight := (bounds.MaxY - bounds.MinY) + 2*margin
// Normalized dimensions (in units of 'distance' = lineSpacing/densityAdjusted)
width := modelWidth / scaleFactor
height := modelHeight / scaleFactor
lowerBound := 0.0
upperBound := height
flip := true
if vertical {
flip = false
lowerBound = -math.Pi
upperBound = width - math.Pi/2.0
width, height = height, width
}
// Generate one period of the wave for odd and even lines
onePeriodOdd := gyroidMakeOnePeriod(width, zCos, zSin, vertical, flip, tolerance)
flip = !flip
onePeriodEven := gyroidMakeOnePeriod(width, zCos, zSin, vertical, flip, tolerance)
// Generate all wave polylines
type polyline struct {
points []model.Vector3
}
var polylines []polyline
originX := bounds.MinX - margin
originY := bounds.MinY - margin
for y0 := lowerBound; y0 < upperBound+1e-6; y0 += math.Pi {
// Odd wave
pts := gyroidMakeWave(onePeriodOdd, width, height, y0, scaleFactor, zCos, zSin, vertical, flip)
if len(pts) > 1 {
// Translate to model coordinates
translated := make([]model.Vector3, len(pts))
for i, p := range pts {
translated[i] = model.Vector3{X: p.X + originX, Y: p.Y + originY, Z: z}
}
polylines = append(polylines, polyline{points: translated})
}
// Even wave
y0 += math.Pi
if y0 < upperBound+1e-6 {
pts2 := gyroidMakeWave(onePeriodEven, width, height, y0, scaleFactor, zCos, zSin, vertical, flip)
if len(pts2) > 1 {
translated := make([]model.Vector3, len(pts2))
for i, p := range pts2 {
translated[i] = model.Vector3{X: p.X + originX, Y: p.Y + originY, Z: z}
}
polylines = append(polylines, polyline{points: translated})
}
}
}
// Convert polylines to segments with zigzag direction
var segments []model.PathSegment
for idx, pl := range polylines {
pts := pl.points
// Zigzag: reverse every other line for efficiency
if idx%2 == 1 {
for i, j := 0, len(pts)-1; i < j; i, j = i+1, j-1 {
pts[i], pts[j] = pts[j], pts[i]
}
}
// Add travel move from last endpoint if needed
if len(segments) > 0 && len(pts) > 0 {
lastEnd := segments[len(segments)-1].End
if lastEnd.Distance(pts[0]) > 1e-6 {
segments = append(segments, model.PathSegment{
Start: lastEnd,
End: pts[0],
IsTravel: true,
})
}
}
for i := 0; i < len(pts)-1; i++ {
segments = append(segments, model.PathSegment{
Start: pts[i],
End: pts[i+1],
IsTravel: false,
})
}
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// gyroidF computes the y-value of the gyroid wave at position x for a given z.
// This is the core mathematical function derived from the gyroid implicit surface:
// sin(x)cos(y) + sin(y)cos(z) + sin(z)cos(x) = 0, solved for y.
func gyroidF(x, zSin, zCos float64, vertical, flip bool) float64 {
if vertical {
phaseOffset := math.Pi
if zCos < 0 {
phaseOffset += math.Pi
}
a := math.Sin(x + phaseOffset)
b := -zCos
flipOffset := 0.0
if flip {
flipOffset = math.Pi
}
res := zSin * math.Cos(x+phaseOffset+flipOffset)
r := math.Sqrt(a*a + b*b)
if r < 1e-10 {
return math.Pi
}
// Clamp to [-1,1] to avoid NaN from asin
return math.Asin(clamp(a/r, -1, 1)) + math.Asin(clamp(res/r, -1, 1)) + math.Pi
}
// Horizontal
phaseOffset := 0.0
if zSin < 0 {
phaseOffset = math.Pi
}
a := math.Cos(x + phaseOffset)
b := -zSin
flipOffset := math.Pi
if flip {
flipOffset = 0.0
}
res := zCos * math.Sin(x+phaseOffset+flipOffset)
r := math.Sqrt(a*a + b*b)
if r < 1e-10 {
return 0.5 * math.Pi
}
return math.Asin(clamp(a/r, -1, 1)) + math.Asin(clamp(res/r, -1, 1)) + 0.5*math.Pi
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// gyroidMakeOnePeriod generates one period of the gyroid wave with adaptive resolution.
// It starts with coarse samples at π/2 intervals, then adaptively subdivides
// until the cross-product tolerance is met.
func gyroidMakeOnePeriod(width, zCos, zSin float64, vertical, flip bool, tolerance float64) [][2]float64 {
dx := math.Pi / 2.0
limit := math.Min(2*math.Pi, width)
var points [][2]float64
// Initial coarse sampling
for x := 0.0; x < limit-1e-10; x += dx {
points = append(points, [2]float64{x, gyroidF(x, zSin, zCos, vertical, flip)})
}
points = append(points, [2]float64{limit, gyroidF(limit, zSin, zCos, vertical, flip)})
// Adaptive refinement
for {
size := len(points)
var newPoints [][2]float64
for i := 1; i < size; i++ {
lp := points[i-1]
rp := points[i]
x := lp[0] + (rp[0]-lp[0])/2.0
y := gyroidF(x, zSin, zCos, vertical, flip)
ip := [2]float64{x, y}
// Cross product of (ip-lp) x (ip-rp) — tests deviation from straight line
dx1 := ip[0] - lp[0]
dy1 := ip[1] - lp[1]
dx2 := ip[0] - rp[0]
dy2 := ip[1] - rp[1]
cross := math.Abs(dx1*dy2 - dy1*dx2)
if cross > tolerance*tolerance {
newPoints = append(newPoints, ip)
}
}
if len(newPoints) == 0 {
break
}
points = append(points, newPoints...)
// Sort by x
sortPoints(points)
}
return points
}
func sortPoints(points [][2]float64) {
// Simple insertion sort — points are nearly sorted
for i := 1; i < len(points); i++ {
key := points[i]
j := i - 1
for j >= 0 && points[j][0] > key[0] {
points[j+1] = points[j]
j--
}
points[j+1] = key
}
}
// gyroidMakeWave extends one period across the full width and returns model-space points.
func gyroidMakeWave(onePeriod [][2]float64, width, height, offset, scaleFactor, zCos, zSin float64, vertical, flip bool) []model.Vector3 {
if len(onePeriod) == 0 {
return nil
}
// Copy and extend the period across the width
period := onePeriod[len(onePeriod)-1][0]
var points [][2]float64
if width > period+1e-6 {
// Copy without last point (it will be the start of the next period)
base := make([][2]float64, len(onePeriod)-1)
copy(base, onePeriod[:len(onePeriod)-1])
n := len(base)
points = append(points, base...)
for points[len(points)-1][0] < width-1e-6 {
idx := len(points) - n
if idx < 0 {
break
}
points = append(points, [2]float64{
points[idx][0] + period,
points[idx][1],
})
}
// Add final point at exact width
points = append(points, [2]float64{width, gyroidF(width, zSin, zCos, vertical, flip)})
} else {
points = make([][2]float64, len(onePeriod))
copy(points, onePeriod)
}
// Convert to model-space Vector3
result := make([]model.Vector3, 0, len(points))
for _, p := range points {
px := p[0]
py := p[1] + offset
// Clamp y to [0, height]
py = clamp(py, 0, height)
if vertical {
px, py = py, px
}
result = append(result, model.Vector3{
X: px * scaleFactor,
Y: py * scaleFactor,
Z: 0, // Z will be set by the caller
})
}
return result
}
// --- Solid Layer Infill Patterns (100% coverage with overlap) ---
// GenerateLineInfillFull creates a solid line infill pattern for top/bottom layers
// Uses overlap to ensure complete coverage
func GenerateLineInfillFull(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64, angle float64) model.ContinuousPath {
// For solid layers, spacing is reduced by overlap to ensure no gaps
spacing := params.LineWidth * (1.0 - params.InfillOverlap)
var segments []model.PathSegment
margin := spacing * 2.0
for y := bounds.MinY - margin; y <= bounds.MaxY+margin; y += spacing {
segments = append(segments, model.PathSegment{
Start: model.Vector3{X: bounds.MinX - margin, Y: y, Z: z},
End: model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z},
IsTravel: false,
})
}
// If angle is specified, rotate all segments
if angle != 0 {
center := model.Vector3{
X: (bounds.MinX + bounds.MaxX) / 2,
Y: (bounds.MinY + bounds.MaxY) / 2,
Z: z,
}
for i := range segments {
segments[i].Start = segments[i].Start.RotateAroundPoint(center, angle)
segments[i].End = segments[i].End.RotateAroundPoint(center, angle)
}
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateRectilinearInfillFull creates a rectilinear (alternating 0°/90°) solid infill
// Alternates direction per layer for better bonding
// Optimized to zigzag each line, eliminating travel moves
func GenerateRectilinearInfillFull(bounds model.BoundingBox, params model.SliceConfig, layerIndex int, z float64) model.ContinuousPath {
spacing := params.LineWidth * (1.0 - params.InfillOverlap)
margin := spacing * 2.0
var segments []model.PathSegment
// Determine direction based on layer index
horizontal := layerIndex%2 == 0
if horizontal {
// Horizontal lines with alternating direction (zigzag)
lineIndex := 0
for y := bounds.MinY - margin; y <= bounds.MaxY+margin; y += spacing {
// Alternate direction: even lines go left-to-right, odd lines go right-to-left
if lineIndex%2 == 0 {
segments = append(segments, model.PathSegment{
Start: model.Vector3{X: bounds.MinX - margin, Y: y, Z: z},
End: model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z},
IsTravel: false,
})
} else {
segments = append(segments, model.PathSegment{
Start: model.Vector3{X: bounds.MaxX + margin, Y: y, Z: z},
End: model.Vector3{X: bounds.MinX - margin, Y: y, Z: z},
IsTravel: false,
})
}
lineIndex++
}
} else {
// Vertical lines with alternating direction (zigzag)
lineIndex := 0
for x := bounds.MinX - margin; x <= bounds.MaxX+margin; x += spacing {
// Alternate direction: even lines go bottom-to-top, odd lines go top-to-bottom
if lineIndex%2 == 0 {
segments = append(segments, model.PathSegment{
Start: model.Vector3{X: x, Y: bounds.MinY - margin, Z: z},
End: model.Vector3{X: x, Y: bounds.MaxY + margin, Z: z},
IsTravel: false,
})
} else {
segments = append(segments, model.PathSegment{
Start: model.Vector3{X: x, Y: bounds.MaxY + margin, Z: z},
End: model.Vector3{X: x, Y: bounds.MinY - margin, Z: z},
IsTravel: false,
})
}
lineIndex++
}
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
// GenerateConcentricInfillFull creates concentric infill following the shell shape
// Creates inward offsets of the shell for complete coverage
// Note: This function still needs a shell polygon, not just bounds, so it takes the shell from context
func GenerateConcentricInfillFull(shell *model.Polygon, params model.SliceConfig, layerIndex int) model.ContinuousPath {
spacing := params.LineWidth * (1.0 - params.InfillOverlap)
var segments []model.PathSegment
// Start from the shell and work inward
currentPolygon := shell
offset := spacing
for currentPolygon != nil && len(currentPolygon.Points) > 2 {
// fmt.Printf("Concentric loop: points=%d area=%.6f\n", len(currentPolygon.Points), currentPolygon.GetArea())
if math.Abs(currentPolygon.GetArea()) < 1e-6 {
break
}
// Create segments for this concentric ring
for j := 0; j < len(currentPolygon.Points); j++ {
nextIdx := (j + 1) % len(currentPolygon.Points)
segments = append(segments, model.PathSegment{
Start: currentPolygon.Points[j],
End: currentPolygon.Points[nextIdx],
IsTravel: false,
})
}
// Offset inward for next ring
next := currentPolygon.OffsetPolygon(-offset)
if next == nil || len(next.Points) < 3 || math.Abs(next.GetArea()) >= math.Abs(currentPolygon.GetArea()) {
break
}
currentPolygon = next
}
return model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
}
}
package core
import (
"math"
"sort"
"github.com/siherrmann/slicer/model"
)
// GenerateLayerPath berechnet den optimierten Werkzeugpfad
func GenerateLayerPath(polygons []model.Polygon, aboveSlice, belowSlice *model.Slice, params model.SliceConfig, modelBounds model.BoundingBox, layerIndex int) []model.ContinuousPath {
var paths []model.ContinuousPath
var shells []model.Polygon
var holes []model.Polygon
for _, p := range polygons {
if p.IsHole {
holes = append(holes, p)
} else {
shells = append(shells, p)
}
}
// Vase mode: single outer wall, no infill, spiral Z interpolation
if params.VaseMode && layerIndex > 0 {
for _, shell := range shells {
// Generate single outer wall
wall := shell.OffsetPolygon(-params.LineWidth * 0.5)
if wall == nil || len(wall.Points) < 3 {
continue
}
z := wall.Points[0].Z
nextZ := z + params.LayerHeight
var segments []model.PathSegment
totalPoints := len(wall.Points)
for j := 0; j < totalPoints; j++ {
nextIdx := (j + 1) % totalPoints
// Interpolate Z across the perimeter for spiral effect
t := float64(j) / float64(totalPoints)
startZ := z + t*(nextZ-z)
t2 := float64(j+1) / float64(totalPoints)
endZ := z + t2*(nextZ-z)
start := model.Vector3{X: wall.Points[j].X, Y: wall.Points[j].Y, Z: startZ}
end := model.Vector3{X: wall.Points[nextIdx].X, Y: wall.Points[nextIdx].Y, Z: endZ}
segments = append(segments, model.PathSegment{
Start: start,
End: end,
IsTravel: false,
Speed: params.OuterShellSpeed,
Category: model.CategoryOuterWall,
})
}
if len(segments) > 0 {
paths = append(paths, model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layerIndex,
})
}
}
return paths
}
// Process each shell completely (walls + infill) before moving to next shell
for _, shell := range shells {
// 1. Print all walls // 2. Standard mode walls
walls := GenerateWalls(shell, params, layerIndex)
paths = append(paths, walls...)
// 2. Print infill for this shell
infillArea := shell.OffsetPolygon(-CalculateInfillOffset(params) + (params.LineWidth*params.InfillOverlap)/2)
if infillArea == nil || len(infillArea.Points) < 3 {
continue
}
// Calculate Z position for this layer
z := shell.Points[0].Z
// Generate the uncut infill patterns
sparsePattern := GenerateInfill(modelBounds, &shell, params, layerIndex, z)
var solidPattern model.ContinuousPath
if params.TopLayers > 0 || params.BottomLayers > 0 {
// Generate solid lines using rectilinear zigzag for 100% density roofs
solidPattern = GenerateRectilinearInfillFull(modelBounds, params, layerIndex, z)
}
if len(sparsePattern.Segments) > 0 {
sparsePaths := cutInfill(sparsePattern, infillArea, holes)
solidPaths := cutInfill(solidPattern, infillArea, holes)
var finalSparseSegments []model.PathSegment
var finalSolidSegments []model.PathSegment
for _, path := range sparsePaths {
for _, seg := range path.Segments {
if seg.IsTravel {
continue
}
mid := seg.Start.Add(seg.End.Sub(seg.Start).Scale(0.5))
// If the midpoint is not covered by ABOVE or BELOW, it's a roof/floor.
isTop := aboveSlice == nil || !aboveSlice.ContainsPoint(mid)
isBottom := belowSlice == nil || !belowSlice.ContainsPoint(mid)
if (isTop && params.TopLayers > 0) || (isBottom && params.BottomLayers > 0) {
// Don't keep sparse infill here, we'll use solid instead.
continue
}
finalSparseSegments = append(finalSparseSegments, seg)
}
}
if len(solidPaths) > 0 {
for _, path := range solidPaths {
for _, seg := range path.Segments {
if seg.IsTravel {
continue
}
mid := seg.Start.Add(seg.End.Sub(seg.Start).Scale(0.5))
isTop := aboveSlice == nil || !aboveSlice.ContainsPoint(mid)
isBottom := belowSlice == nil || !belowSlice.ContainsPoint(mid)
if (isTop && params.TopLayers > 0) || (isBottom && params.BottomLayers > 0) {
seg.Category = model.CategorySolidInfill
finalSolidSegments = append(finalSolidSegments, seg)
}
}
}
}
// Assemble sparse and solid segments into continuous paths to minimize travel
if len(finalSparseSegments) > 0 {
paths = append(paths, optimizeSegmentsToPaths(finalSparseSegments, layerIndex, model.CategoryInfill)...)
}
if len(finalSolidSegments) > 0 {
paths = append(paths, optimizeSegmentsToPaths(finalSolidSegments, layerIndex, model.CategorySolidInfill)...)
}
}
}
return paths
}
// optimizeSegmentsToPaths chains disconnected segments together with minimal travel moves
func optimizeSegmentsToPaths(segments []model.PathSegment, layerIndex int, category model.PathCategory) []model.ContinuousPath {
if len(segments) == 0 {
return nil
}
var paths []model.ContinuousPath
var currentPath model.ContinuousPath
currentPath.LayerIndex = layerIndex
currentPath.PathType = model.PathExtrusion
used := make([]bool, len(segments))
// Start with first segment
firstSeg := segments[0]
firstSeg.Category = category
currentPath.Segments = append(currentPath.Segments, firstSeg)
used[0] = true
currentPoint := firstSeg.End
for {
bestDist := math.MaxFloat64
bestIdx := -1
reverse := false
for i, seg := range segments {
if used[i] {
continue
}
d1 := currentPoint.Distance(seg.Start)
if d1 < bestDist {
bestDist = d1
bestIdx = i
reverse = false
}
d2 := currentPoint.Distance(seg.End)
if d2 < bestDist {
bestDist = d2
bestIdx = i
reverse = true
}
}
if bestIdx == -1 {
break
}
nextSeg := segments[bestIdx]
if reverse {
nextSeg.Start, nextSeg.End = nextSeg.End, nextSeg.Start
}
// Add travel move if disconnected
if bestDist > 0.01 {
currentPath.Segments = append(currentPath.Segments, model.PathSegment{
Start: currentPoint,
End: nextSeg.Start,
IsTravel: true,
Category: model.CategoryTravel,
})
}
nextSeg.Category = category
currentPath.Segments = append(currentPath.Segments, nextSeg)
used[bestIdx] = true
currentPoint = nextSeg.End
}
paths = append(paths, currentPath)
return paths
}
// --- Infill Cutting ---
// intersectionPoint represents an intersection with its position along a segment
type intersectionPoint struct {
Point model.Vector3
T float64 // Parameter t (0 to 1) along the segment
}
// cutInfill takes an uncut infill pattern and trims it to fit within the shell and avoid holes.
// All coordinates are in model space — no rotation is applied.
func cutInfill(pattern model.ContinuousPath, shell *model.Polygon, holes []model.Polygon) []model.ContinuousPath {
var resultSegments []model.PathSegment
var lastEndPoint *model.Vector3
// Get shell bounds for quick skipping
shellBounds := shell.GetBounds()
// Pre-calculate hole bounds
holeBounds := make([]model.BoundingBox, len(holes))
for i, hole := range holes {
holeBounds[i] = hole.GetBounds()
}
// Get shell lines once
shellLines := shell.GetLines()
// Process each segment in the pattern
for _, segment := range pattern.Segments {
// Quick bounding box check against shell
segMinX := math.Min(segment.Start.X, segment.End.X)
segMaxX := math.Max(segment.Start.X, segment.End.X)
segMinY := math.Min(segment.Start.Y, segment.End.Y)
segMaxY := math.Max(segment.Start.Y, segment.End.Y)
if segMaxX < shellBounds.MinX || segMinX > shellBounds.MaxX ||
segMaxY < shellBounds.MinY || segMinY > shellBounds.MaxY {
continue // Segment is completely outside shell bounds
}
infillLine := model.LineSegment{
Start: segment.Start,
End: segment.End,
}
var intersections []intersectionPoint
// Helper to extract intersection parameter T and store correctly
addIntersections := func(lines []model.LineSegment) {
for _, line := range lines {
intersection, ok := infillLine.IntersectSegments(line)
if ok {
segmentVec := segment.End.Sub(segment.Start)
intersectVec := intersection.Sub(segment.Start)
segmentLength := math.Sqrt(segmentVec.X*segmentVec.X + segmentVec.Y*segmentVec.Y)
if segmentLength < 1e-10 {
continue
}
intersectLength := math.Sqrt(intersectVec.X*intersectVec.X + intersectVec.Y*intersectVec.Y)
t := intersectLength / segmentLength
// Check sign (intersectVec should be in same direction as segmentVec)
if segmentVec.X*intersectVec.X+segmentVec.Y*intersectVec.Y < 0 {
t = -t
}
if t >= -1e-6 && t <= 1.0+1e-6 {
t = math.Max(0, math.Min(1, t))
intersections = append(intersections, intersectionPoint{Point: intersection, T: t})
}
}
}
}
// Find intersections with shell edges
addIntersections(shellLines)
// Find intersections with holes
for i, hole := range holes {
hB := holeBounds[i]
if segMaxX < hB.MinX || segMinX > hB.MaxX ||
segMaxY < hB.MinY || segMinY > hB.MaxY {
continue
}
addIntersections(hole.GetLines())
}
// If no intersections, check if the entire segment is inside the shell
if len(intersections) == 0 {
segmentInside := shell.ContainsPoint(segment.Start) && shell.ContainsPoint(segment.End)
if segmentInside {
// Check if it's not in any hole
inHole := false
for _, hole := range holes {
if hole.ContainsPoint(segment.Start) || hole.ContainsPoint(segment.End) {
inHole = true
break
}
}
if !inHole {
// Add travel move if needed
if lastEndPoint != nil && lastEndPoint.Distance(segment.Start) > 1e-6 {
resultSegments = append(resultSegments, model.PathSegment{
Start: *lastEndPoint,
End: segment.Start,
IsTravel: true,
Category: model.CategoryTravel,
})
}
resultSegments = append(resultSegments, segment)
endPoint := segment.End
lastEndPoint = &endPoint
}
}
continue
}
// Sort intersections by parameter t (position along the segment)
sort.Slice(intersections, func(i, j int) bool {
return intersections[i].T < intersections[j].T
})
// Remove duplicate intersections (too close together)
var deduped []intersectionPoint
for i, ip := range intersections {
if i == 0 || ip.T-deduped[len(deduped)-1].T > 1e-6 {
deduped = append(deduped, ip)
}
}
intersections = deduped
// Build candidate sub-segments by testing midpoints
// Create boundary points: [0 (start), intersections..., 1 (end)]
tValues := []float64{0}
for _, ip := range intersections {
tValues = append(tValues, ip.T)
}
tValues = append(tValues, 1)
segVec := segment.End.Sub(segment.Start)
for i := 0; i < len(tValues)-1; i++ {
t1 := tValues[i]
t2 := tValues[i+1]
if t2-t1 < 1e-8 {
continue
}
// Test midpoint
midT := (t1 + t2) / 2.0
midPoint := segment.Start.Add(segVec.Scale(midT))
// Check if midpoint is inside shell and outside all holes
if !shell.ContainsPoint(midPoint) {
continue
}
inHole := false
for _, hole := range holes {
if hole.ContainsPoint(midPoint) {
inHole = true
break
}
}
if inHole {
continue
}
// This sub-segment is valid
p1 := segment.Start.Add(segVec.Scale(t1))
p2 := segment.Start.Add(segVec.Scale(t2))
if lastEndPoint != nil && lastEndPoint.Distance(p1) > 1e-6 {
resultSegments = append(resultSegments, model.PathSegment{
Start: *lastEndPoint,
End: p1,
IsTravel: true,
Category: model.CategoryTravel,
})
}
resultSegments = append(resultSegments, model.PathSegment{
Start: p1,
End: p2,
IsTravel: false,
Category: segment.Category,
Speed: segment.Speed,
FlowRate: segment.FlowRate,
})
lastEndPoint = &p2
}
}
if len(resultSegments) == 0 {
return nil
}
return []model.ContinuousPath{{
Segments: resultSegments,
PathType: model.PathExtrusion,
LayerIndex: pattern.LayerIndex, // preserve layer index
}}
}
package core
import (
"github.com/siherrmann/slicer/model"
)
// GenerateRaft creates raft layers below the model for better bed adhesion.
// Returns the raft paths and the Z offset that the model should be shifted up by.
func GenerateRaft(firstLayerPolygons []model.Polygon, config model.SliceConfig) ([]model.ContinuousPath, float64) {
if config.RaftLayers <= 0 {
return nil, 0
}
var paths []model.ContinuousPath
// Get all non-hole shells from the first layer
var shells []model.Polygon
for _, p := range firstLayerPolygons {
if !p.IsHole {
shells = append(shells, p)
}
}
if len(shells) == 0 {
return nil, 0
}
// Expand each shell by raft offset to create raft outline
var raftOutlines []*model.Polygon
for _, shell := range shells {
expanded := shell.OffsetPolygon(config.RaftOffset)
if expanded != nil && len(expanded.Points) > 2 {
raftOutlines = append(raftOutlines, expanded)
}
}
if len(raftOutlines) == 0 {
return nil, 0
}
// Calculate Z heights for raft layers
// Base raft layer is thicker (first layer height), subsequent layers use regular height
raftHeight := float64(config.RaftLayers) * config.FirstLayer
for layer := 0; layer < config.RaftLayers; layer++ {
z := config.FirstLayer * float64(layer+1)
// Alternate direction between layers
isBase := layer < config.RaftLayers/2+1
// Line spacing: base layers are wider spaced, top layers are tighter
spacing := config.LineWidth * 3.0
if !isBase {
spacing = config.LineWidth * 1.5
}
for _, outline := range raftOutlines {
bounds := outline.GetBounds()
var segments []model.PathSegment
if layer%2 == 0 {
// Horizontal lines
for y := bounds.MinY; y <= bounds.MaxY; y += spacing {
start := model.Vector3{X: bounds.MinX - 0.5, Y: y, Z: z}
end := model.Vector3{X: bounds.MaxX + 0.5, Y: y, Z: z}
line := model.LineSegment{Start: start, End: end}
clipped := outline.ClipLineToPolygon(line)
for _, seg := range clipped {
if len(segments) > 0 {
lastEnd := segments[len(segments)-1].End
if lastEnd.Distance(seg.Start) > 0.01 {
segments = append(segments, model.PathSegment{
Start: lastEnd,
End: seg.Start,
IsTravel: true,
Category: model.CategoryTravel,
})
}
}
segments = append(segments, model.PathSegment{
Start: seg.Start,
End: seg.End,
IsTravel: false,
Speed: config.FirstLayerSpeed,
Category: model.CategorySupport,
})
}
}
} else {
// Vertical lines
for x := bounds.MinX; x <= bounds.MaxX; x += spacing {
start := model.Vector3{X: x, Y: bounds.MinY - 0.5, Z: z}
end := model.Vector3{X: x, Y: bounds.MaxY + 0.5, Z: z}
line := model.LineSegment{Start: start, End: end}
clipped := outline.ClipLineToPolygon(line)
for _, seg := range clipped {
if len(segments) > 0 {
lastEnd := segments[len(segments)-1].End
if lastEnd.Distance(seg.Start) > 0.01 {
segments = append(segments, model.PathSegment{
Start: lastEnd,
End: seg.Start,
IsTravel: true,
Category: model.CategoryTravel,
})
}
}
segments = append(segments, model.PathSegment{
Start: seg.Start,
End: seg.End,
IsTravel: false,
Speed: config.FirstLayerSpeed,
Category: model.CategorySupport,
})
}
}
}
if len(segments) > 0 {
paths = append(paths, model.ContinuousPath{
Segments: segments,
PathType: model.PathExtrusion,
LayerIndex: layer,
})
}
}
}
return paths, raftHeight
}
package core
import (
"github.com/siherrmann/slicer/model"
)
// GenerateSkirt generates skirt or brim paths around the base of the model
func GenerateSkirt(firstLayer []model.Polygon, params model.SliceConfig) []model.ContinuousPath {
if params.SkirtCount <= 0 && params.BrimCount <= 0 {
return nil
}
var paths []model.ContinuousPath
// 1. Identify all outer shells on the first layer
var shells []model.Polygon
for _, p := range firstLayer {
if !p.IsHole {
shells = append(shells, p)
}
}
if len(shells) == 0 {
return nil
}
// 2. Generate Brim (directly attached)
if params.BrimCount > 0 {
for _, shell := range shells {
for i := 1; i <= params.BrimCount; i++ {
offset := float64(i) * params.LineWidth
brimLine := shell.OffsetPolygon(offset)
if brimLine != nil && len(brimLine.Points) > 2 {
brimLine.IsClosed = true
paths = append(paths, brimLine.ToContinuousPath(params.FirstLayerSpeed, model.CategoryOuterWall, 0))
}
}
}
}
// 3. Generate Skirt (offset from model)
if params.SkirtCount > 0 {
for _, shell := range shells {
for i := 0; i < params.SkirtCount; i++ {
// Base offset + line index * width
offset := params.SkirtOffset + float64(i)*params.LineWidth
skirtLine := shell.OffsetPolygon(offset)
if skirtLine != nil && len(skirtLine.Points) > 2 {
skirtLine.IsClosed = true
paths = append(paths, skirtLine.ToContinuousPath(params.FirstLayerSpeed, model.CategoryOuterWall, 0))
}
}
}
}
return paths
}
package core
import (
"fmt"
"math"
"sort"
"github.com/siherrmann/slicer/model"
)
// Grid represents a 2D boolean grid for a layer
type Grid struct {
Width, Height int
Resolution float64
MinX, MinY float64
Cells []bool
}
func (g *Grid) GetBounds() model.BoundingBox {
return model.BoundingBox{
MinX: g.MinX,
MinY: g.MinY,
MaxX: g.MinX + float64(g.Width)*g.Resolution,
MaxY: g.MinY + float64(g.Height)*g.Resolution,
}
}
func NewGrid(bounds model.BoundingBox, resolution float64) *Grid {
width := int(math.Ceil((bounds.MaxX - bounds.MinX) / resolution))
height := int(math.Ceil((bounds.MaxY - bounds.MinY) / resolution))
return &Grid{
Width: width,
Height: height,
Resolution: resolution,
MinX: bounds.MinX,
MinY: bounds.MinY,
Cells: make([]bool, width*height),
}
}
func (g *Grid) Index(x, y int) int {
if x < 0 || x >= g.Width || y < 0 || y >= g.Height {
return -1
}
return y*g.Width + x
}
func (g *Grid) Set(x, y int, val bool) {
idx := g.Index(x, y)
if idx != -1 {
g.Cells[idx] = val
}
}
func (g *Grid) Get(x, y int) bool {
idx := g.Index(x, y)
if idx != -1 {
return g.Cells[idx]
}
return false
}
// SetDisk sets a circular area to true centered at cx, cy with given radius
func (g *Grid) SetDisk(cx, cy, radius int) {
if radius <= 0 {
g.Set(cx, cy, true)
return
}
r2 := radius * radius
for dy := -radius; dy <= radius; dy++ {
for dx := -radius; dx <= radius; dx++ {
if dx*dx+dy*dy <= r2 {
// g.Set checks bounds
g.Set(cx+dx, cy+dy, true)
}
}
}
}
// SetDiskFloat sets a circular area to true centered at cx, cy with given float radius
func (g *Grid) SetDiskFloat(cx, cy float64, radius float64) {
if radius <= 0 {
return
}
r2 := radius * radius
bound := int(math.Ceil(radius))
icx := int(cx)
icy := int(cy)
for dy := -bound - 1; dy <= bound+1; dy++ {
for dx := -bound - 1; dx <= bound+1; dx++ {
x := icx + dx
y := icy + dy
// distance from pixel center to cx,cy
dfx := float64(x) - cx
dfy := float64(y) - cy
if dfx*dfx+dfy*dfy <= r2 {
if x >= 0 && x < g.Width && y >= 0 && y < g.Height {
g.Cells[y*g.Width+x] = true
}
}
}
}
}
// RasterizePolygons fills the grid based on polygons using Scanline Even-Odd rule
func (g *Grid) RasterizePolygons(polygons []model.Polygon) {
for j := 0; j < g.Height; j++ {
y := g.MinY + float64(j)*g.Resolution + g.Resolution/2
var intersections []float64
for _, p := range polygons {
bounds := p.GetBounds()
if y >= bounds.MinY && y <= bounds.MaxY {
intersections = append(intersections, p.IntersectLine(y)...)
}
}
if len(intersections) == 0 {
continue
}
sort.Float64s(intersections)
// Fill between pairs (Even-Odd rule handles holes automatically)
for k := 0; k < len(intersections)-1; k += 2 {
x1 := intersections[k]
x2 := intersections[k+1]
startI := int((x1 - g.MinX) / g.Resolution)
endI := int((x2 - g.MinX) / g.Resolution)
startI = maxInt(0, startI)
endI = minInt(g.Width-1, endI)
for i := startI; i <= endI; i++ {
g.Set(i, j, true)
}
}
}
}
// DistanceField computes a 2D distance transform of the grid.
// Returns a slice of float64 where each element is the distance in grid cells
// to the nearest TRUE cell. TRUE cells have a distance of 0.
// This uses a fast 2-pass sweep algorithm (8SSE).
func (g *Grid) DistanceField() []float64 {
dist := make([]float64, g.Width*g.Height)
// Initialize distances
maxDist := float64(g.Width + g.Height) // Safe upper bound
for i, val := range g.Cells {
if val {
dist[i] = 0
} else {
dist[i] = maxDist
}
}
// Pass 1: Top-Left to Bottom-Right
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
idx := y*g.Width + x
if dist[idx] == 0 {
continue
}
minD := dist[idx]
// Check West
if x > 0 {
d := dist[idx-1] + 1.0
if d < minD {
minD = d
}
}
// Check North-West
if x > 0 && y > 0 {
d := dist[idx-g.Width-1] + math.Sqrt2
if d < minD {
minD = d
}
}
// Check North
if y > 0 {
d := dist[idx-g.Width] + 1.0
if d < minD {
minD = d
}
}
// Check North-East
if x < g.Width-1 && y > 0 {
d := dist[idx-g.Width+1] + math.Sqrt2
if d < minD {
minD = d
}
}
dist[idx] = minD
}
}
// Pass 2: Bottom-Right to Top-Left
for y := g.Height - 1; y >= 0; y-- {
for x := g.Width - 1; x >= 0; x-- {
idx := y*g.Width + x
minD := dist[idx]
if minD == 0 {
continue
}
// Check East
if x < g.Width-1 {
d := dist[idx+1] + 1.0
if d < minD {
minD = d
}
}
// Check South-East
if x < g.Width-1 && y < g.Height-1 {
d := dist[idx+g.Width+1] + math.Sqrt2
if d < minD {
minD = d
}
}
// Check South
if y < g.Height-1 {
d := dist[idx+g.Width] + 1.0
if d < minD {
minD = d
}
}
// Check South-West
if x > 0 && y < g.Height-1 {
d := dist[idx+g.Width-1] + math.Sqrt2
if d < minD {
minD = d
}
}
dist[idx] = minD
}
}
return dist
}
// Dilate expands the grid by steps using a circular kernel (approx)
func (g *Grid) Dilate(steps int) *Grid {
if steps <= 0 {
return g
}
newGrid := NewGrid(model.BoundingBox{
MinX: g.MinX, MinY: g.MinY,
MaxX: g.MinX + float64(g.Width)*g.Resolution,
MaxY: g.MinY + float64(g.Height)*g.Resolution,
}, g.Resolution)
// Precompute circle offsets to avoid sqrt per pixel
type Offset struct{ dx, dy int }
var offsets []Offset
r2 := steps * steps
for dy := -steps; dy <= steps; dy++ {
for dx := -steps; dx <= steps; dx++ {
if dx*dx+dy*dy <= r2 {
offsets = append(offsets, Offset{dx, dy})
}
}
}
for j := 0; j < g.Height; j++ {
for i := 0; i < g.Width; i++ {
if g.Get(i, j) {
for _, off := range offsets {
nx, ny := i+off.dx, j+off.dy
// inline boundary check for speed
if nx >= 0 && nx < g.Width && ny >= 0 && ny < g.Height {
newGrid.Cells[ny*g.Width+nx] = true
}
}
}
}
}
return newGrid
}
// Erode shrinks the grid by steps using a circular kernel
// Erode is equivalent to Dilating the FALSE regions (Background)
// Or: A cell survives ONLY IF all neighbors in radius are TRUE.
func (g *Grid) Erode(steps int) *Grid {
if steps <= 0 {
return g
}
newGrid := NewGrid(model.BoundingBox{
MinX: g.MinX, MinY: g.MinY,
MaxX: g.MinX + float64(g.Width)*g.Resolution,
MaxY: g.MinY + float64(g.Height)*g.Resolution,
}, g.Resolution)
// Precompute circle offsets
type Offset struct{ dx, dy int }
var offsets []Offset
r2 := steps * steps
for dy := -steps; dy <= steps; dy++ {
for dx := -steps; dx <= steps; dx++ {
if dx*dx+dy*dy <= r2 {
offsets = append(offsets, Offset{dx, dy})
}
}
}
// Iterate all cells. If any neighbor in radius is FALSE, result is FALSE.
// Optimization: Iterate TRUE cells. Check neighbors.
// But Erode can turn TRUE into FALSE. It never keeps FALSE as TRUE.
// So we only check current TRUE cells.
for j := 0; j < g.Height; j++ {
for i := 0; i < g.Width; i++ {
if g.Get(i, j) {
keep := true
for _, off := range offsets {
nx, ny := i+off.dx, j+off.dy
if nx < 0 || nx >= g.Width || ny < 0 || ny >= g.Height || !g.Get(nx, ny) {
keep = false
break
}
}
if keep {
newGrid.Set(i, j, true)
}
}
}
}
return newGrid
}
// Open performs morphological Opening (Erode then Dilate) to remove noise/smooth
func (g *Grid) Open(steps int) *Grid {
return g.Erode(steps).Dilate(steps)
}
// Close performs morphological Closing (Dilate then Erode) to fill holes/smooth
func (g *Grid) Close(steps int) *Grid {
return g.Dilate(steps).Erode(steps)
}
// TraceContours extracts the boundaries of connected components as polygons
// utilizing the Moore-Neighbor Tracing algorithm
func (g *Grid) TraceContours() []model.Polygon {
var contours []model.Polygon
visited := make([]bool, g.Width*g.Height)
// Moore-Neighbor tracing requires a starting pixel on the boundary.
// We scan the grid to find an unvisited TRUE pixel that has a FALSE neighbor (or edge) to its left (or just any boundary).
// Since we scan left-to-right, top-to-bottom, the first TRUE pixel we find for a component
// is guaranteed to be on the external boundary.
for j := 0; j < g.Height; j++ {
for i := 0; i < g.Width; i++ {
idx := j*g.Width + i
if g.Cells[idx] && !visited[idx] {
// Found a start point for a new component
// Trace the boundary
path := g.traceBoundary(i, j, visited)
if len(path) > 2 {
// Simplify path (remove collinear points)
simplified := simplifyPath(path)
if len(simplified) > 2 {
contours = append(contours, model.Polygon{Points: simplified})
}
}
}
}
}
return contours
}
func (g *Grid) traceBoundary(startX, startY int, visited []bool) []model.Vector3 {
var path []model.Vector3
// Directions (dx, dy) in grid coordinates (i, j)
// Order: N, NE, E, SE, S, SW, W, NW (Clockwise)
// j+1 is UP (North)
dirs := [8][2]int{
{0, 1}, {1, 1}, {1, 0}, {1, -1},
{0, -1}, {-1, -1}, {-1, 0}, {-1, 1},
}
// Start with backtrack direction = West (since we entered from left)
startIdx := startY*g.Width + startX
// 1. Flood fill component to mark 'visited' so main loop doesn't restart here.
queue := []int{startIdx}
visited[startIdx] = true
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
cx, cy := curr%g.Width, curr/g.Width
// 4-connected flood fill sufficient for marking component
ndirs := [][2]int{{0, 1}, {0, -1}, {1, 0}, {-1, 0}}
for _, d := range ndirs {
nx, ny := cx+d[0], cy+d[1]
if nx >= 0 && nx < g.Width && ny >= 0 && ny < g.Height {
nidx := ny*g.Width + nx
if g.Cells[nidx] && !visited[nidx] {
visited[nidx] = true
queue = append(queue, nidx)
}
}
}
}
// 2. Trace Boundary
// Backtrack direction relative to current pixel
// Start by coming from West (6)
entryDir := 6
currX, currY := startX, startY
first := true
// Helper to get pixel value safely
get := func(mx, my int) bool {
if mx < 0 || mx >= g.Width || my < 0 || my >= g.Height {
return false
}
return g.Get(mx, my)
}
// Jacob's stopping criteria variables
startPoint := [2]int{startX, startY}
var secondPoint [2]int
// Limit iterations to prevent infinite loop
maxIter := g.Width * g.Height * 4
iter := 0
for {
iter++
if iter > maxIter {
break
}
// Add current point to path
// Convert grid coord to world
wx := g.MinX + float64(currX)*g.Resolution + g.Resolution/2 // Center of pixel
wy := g.MinY + float64(currY)*g.Resolution + g.Resolution/2
path = append(path, model.Vector3{X: wx, Y: wy, Z: 0}) // Z set by caller
// Search for next clockwise neighbor
foundNext := false
searchStart := (entryDir + 1)
if first {
searchStart = 6 + 1 // Came from West (empty space)
}
for k := 0; k < 8; k++ {
idx := (searchStart + k) % 8
dx, dy := dirs[idx][0], dirs[idx][1]
nx, ny := currX+dx, currY+dy
if get(nx, ny) {
// Found next boundary pixel
if first {
secondPoint = [2]int{nx, ny}
first = false
} else {
// Check stopping condition
// Stop if we are at Start Point AND next point is Second Point
if currX == startPoint[0] && currY == startPoint[1] && nx == secondPoint[0] && ny == secondPoint[1] {
return path // Closed loop
}
}
// Move to next
currX, currY = nx, ny
// Update entry direction
entryDir = (idx + 4) % 8
foundNext = true
break
}
}
if !foundNext {
// Isolated pixel
break
}
}
return path
}
func simplifyPath(points []model.Vector3) []model.Vector3 {
if len(points) < 3 {
return points
}
var res []model.Vector3
res = append(res, points[0])
for i := 1; i < len(points)-1; i++ {
prev := points[i-1]
curr := points[i]
next := points[i+1]
// Check collinearity 2D
dx1, dy1 := curr.X-prev.X, curr.Y-prev.Y
dx2, dy2 := next.X-curr.X, next.Y-curr.Y
// Cross product close to zero?
cross := dx1*dy2 - dx2*dy1
if math.Abs(cross) > 1e-6 {
res = append(res, curr)
}
}
res = append(res, points[len(points)-1])
return res
}
func smoothPath(points []model.Vector3, iterations int) []model.Vector3 {
if len(points) < 3 || iterations <= 0 {
return points
}
result := points
for i := 0; i < iterations; i++ {
var smoothed []model.Vector3
n := len(result)
for j := 0; j < n; j++ {
p1 := result[j]
p2 := result[(j+1)%n]
// Compute the two new points at 1/4 and 3/4 along the segment
q := model.Vector3{
X: p1.X + 0.25*(p2.X-p1.X),
Y: p1.Y + 0.25*(p2.Y-p1.Y),
Z: p1.Z,
}
r := model.Vector3{
X: p1.X + 0.75*(p2.X-p1.X),
Y: p1.Y + 0.75*(p2.Y-p1.Y),
Z: p1.Z,
}
smoothed = append(smoothed, q, r)
}
result = smoothed
}
return result
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// GenerateSupportPaths generates support paths using Vector-Based Tree logic or Raster
func GenerateSupportPaths(bm *model.BaseModel, config model.SliceConfig) map[int][]model.ContinuousPath {
if config.SupportType == model.SupportNone || len(bm.Slices) < 2 {
return nil
}
resolution := config.LineWidth // e.g. 0.4mm grid
bounds := bm.GetBounds()
// Dynamic padding for tree support spreading
padding := 2.0
if config.SupportType == model.SupportTree && len(bm.Slices) > 0 {
height := bm.Slices[len(bm.Slices)-1].Z
// tan(50 degrees) approx 1.2
padding = height*1.5 + 5.0
}
bounds.MinX -= padding
bounds.MinY -= padding
bounds.MaxX += padding
bounds.MaxY += padding
// 1. Voxelize Model
layerGrids := make([]*Grid, len(bm.Slices))
for i, slice := range bm.Slices {
g := NewGrid(bounds, resolution)
g.RasterizePolygons(slice.Polygons)
layerGrids[i] = g
}
supportGrids := make([]*Grid, len(bm.Slices))
for i := range supportGrids {
supportGrids[i] = NewGrid(bounds, resolution)
}
// 2. Identify Overhangs & Propagate Down
// "Support Angle" is measured from the vertical Z-axis (0 = vertical wall, 90 = flat horizontal roof).
// This is standard for 3D slicers (like Cura).
// higher angle = requires flatter surface to trigger support = LESS support.
var overhangDist float64
if config.SupportAngle <= 0.1 {
overhangDist = 0.0001 // Support almost everything
} else if config.SupportAngle >= 89.9 {
overhangDist = 9999.0 // Prevent support
} else {
overhangDist = config.LayerHeight * math.Tan(config.SupportAngle*math.Pi/180.0)
}
// We add a strict 0.5 grid step buffer (0.2mm) to ignore microscopic texture overhangs
// like fur or ripples that mathematically slope downwards but don't geometrically need support.
overhangDistSteps := (overhangDist / resolution) + 0.5
// Skeleton Tree Data Structures
type Branch struct {
X, Y float64
VX, VY float64 // Velocity for smooth organic curves
Radius float64 // In grid steps
Age int // How many layers this branch has dropped
Weight int // Number of unified tips (used for thickness/stiffness)
Dead bool
}
var activeBranches []*Branch
treeBranchesByLayer := make([][]*Branch, len(bm.Slices))
// ExtractBranchContours uses marching squares on a metaball field generated by the branches
// It guarantees pixel-perfect smooth contours without stair-stepping.
ExtractBranchContours := func(branches []*Branch, gridResolution, resolution, threshold float64, bounds model.BoundingBox) []model.Polygon {
var polygons []model.Polygon
if len(branches) == 0 {
return polygons
}
// Grid bounds
minX := bounds.MinX
minY := bounds.MinY
cols := int(math.Ceil((bounds.MaxX-minX)/resolution)) + 1
rows := int(math.Ceil((bounds.MaxY-minY)/resolution)) + 1
// Evaluate scalar field
field := make([]float64, cols*rows)
for _, b := range branches {
cx := b.X*gridResolution + bounds.MinX
cy := b.Y*gridResolution + bounds.MinY
radiusWorld := b.Radius * gridResolution
r2 := radiusWorld * radiusWorld
// Only evaluate near the branch bounding box for performance
startX := int(math.Floor((cx - radiusWorld - bounds.MinX) / resolution))
endX := int(math.Ceil((cx+radiusWorld-bounds.MinX)/resolution)) + 1
startY := int(math.Floor((cy - radiusWorld - bounds.MinY) / resolution))
endY := int(math.Ceil((cy+radiusWorld-bounds.MinY)/resolution)) + 1
if startX < 0 {
startX = 0
}
if endX > cols {
endX = cols
}
if startY < 0 {
startY = 0
}
if endY > rows {
endY = rows
}
for y := startY; y < endY; y++ {
wy := float64(y)*resolution + minY
for x := startX; x < endX; x++ {
wx := float64(x)*resolution + minX
dx := wx - cx
dy := wy - cy
dist2 := dx*dx + dy*dy
if dist2 < 0.0001 {
dist2 = 0.0001
}
// Inverse square falloff (Max-Metaball)
// Instead of additive (which causes massive ballooning at Y-junctions),
// we take the Max so perfectly overlapping branches stay their exact mathematical radius.
fieldVal := r2 / dist2
if fieldVal > field[y*cols+x] {
field[y*cols+x] = fieldVal
}
}
}
}
// Simple thresholding: turn into boolean grid of inner/outer for contour extraction
// Since we already have a robust Moore-Neighbor contour tracer in `Grid`, we can adapt it!
// However, to avoid the stepping issue entirely, we need Sub-Pixel contouring.
interpolate := func(v1, v2 float64) float64 {
// Linear interpolation for zero-crossing
// We want where value = threshold
if math.Abs(v1-v2) < 0.00001 {
return 0.5
}
return (threshold - v1) / (v2 - v1)
}
type Seg struct {
Start, End model.Vector3
}
var segments []Seg
// Marching Squares lookup table (lines)
for y := 0; y < rows-1; y++ {
wy := float64(y)*resolution + minY
for x := 0; x < cols-1; x++ {
wx := float64(x)*resolution + minX
v0 := field[(y+1)*cols+x] // Top-Left (since y+1 is mathematically "down" in our arrays, wait... Y-up or Y-down?)
v1 := field[(y+1)*cols+(x+1)] // Top-Right
v2 := field[y*cols+(x+1)] // Bottom-Right
v3 := field[y*cols+x] // Bottom-Left
idx := 0
if v0 >= threshold {
idx |= 8
}
if v1 >= threshold {
idx |= 4
}
if v2 >= threshold {
idx |= 2
}
if v3 >= threshold {
idx |= 1
}
if idx == 0 || idx == 15 {
continue
}
// Edge midpoints
topX := wx + interpolate(v0, v1)*resolution
topY := wy + resolution
rightX := wx + resolution
rightY := wy + interpolate(v2, v1)*resolution
bottomX := wx + interpolate(v3, v2)*resolution
bottomY := wy
leftX := wx
leftY := wy + interpolate(v3, v0)*resolution
var pts []model.Vector3
switch idx {
case 1:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: bottomX, Y: bottomY}}
case 2:
pts = []model.Vector3{{X: bottomX, Y: bottomY}, {X: rightX, Y: rightY}}
case 3:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: rightX, Y: rightY}}
case 4:
pts = []model.Vector3{{X: topX, Y: topY}, {X: rightX, Y: rightY}}
case 5:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: topX, Y: topY}, {X: bottomX, Y: bottomY}, {X: rightX, Y: rightY}} // Ambiguous
case 6:
pts = []model.Vector3{{X: topX, Y: topY}, {X: bottomX, Y: bottomY}}
case 7:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: topX, Y: topY}}
case 8:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: topX, Y: topY}}
case 9:
pts = []model.Vector3{{X: topX, Y: topY}, {X: bottomX, Y: bottomY}}
case 10:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: bottomX, Y: bottomY}, {X: topX, Y: topY}, {X: rightX, Y: rightY}} // Ambiguous
case 11:
pts = []model.Vector3{{X: topX, Y: topY}, {X: rightX, Y: rightY}}
case 12:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: rightX, Y: rightY}}
case 13:
pts = []model.Vector3{{X: bottomX, Y: bottomY}, {X: rightX, Y: rightY}}
case 14:
pts = []model.Vector3{{X: leftX, Y: leftY}, {X: bottomX, Y: bottomY}}
}
if len(pts) == 2 {
segments = append(segments, Seg{pts[0], pts[1]})
} else if len(pts) == 4 {
segments = append(segments, Seg{pts[0], pts[1]}, Seg{pts[2], pts[3]})
}
}
}
// Link segments into continuous polygons
// (Simplistic O(N^2) linker for now, tree branches produce very few segments compared to whole models)
if len(segments) == 0 {
return polygons
}
used := make([]bool, len(segments))
for iter := 0; iter < len(segments); iter++ {
if used[iter] {
continue
}
used[iter] = true
poly := model.Polygon{Points: []model.Vector3{segments[iter].Start, segments[iter].End}}
// Try to stitch
foundLink := true
for foundLink {
foundLink = false
lastPt := poly.Points[len(poly.Points)-1]
firstPt := poly.Points[0]
for j := 0; j < len(segments); j++ {
if used[j] {
continue
}
// Match end to start
if math.Abs(segments[j].Start.X-lastPt.X) < 0.001 && math.Abs(segments[j].Start.Y-lastPt.Y) < 0.001 {
poly.Points = append(poly.Points, segments[j].End)
used[j] = true
foundLink = true
break
}
// Match end to end (reversed)
if math.Abs(segments[j].End.X-lastPt.X) < 0.001 && math.Abs(segments[j].End.Y-lastPt.Y) < 0.001 {
poly.Points = append(poly.Points, segments[j].Start)
used[j] = true
foundLink = true
break
}
// Match start to start (prepend)
if math.Abs(segments[j].Start.X-firstPt.X) < 0.001 && math.Abs(segments[j].Start.Y-firstPt.Y) < 0.001 {
poly.Points = append([]model.Vector3{segments[j].End}, poly.Points...)
used[j] = true
foundLink = true
break
}
// Match start to end (prepend)
if math.Abs(segments[j].End.X-firstPt.X) < 0.001 && math.Abs(segments[j].End.Y-firstPt.Y) < 0.001 {
poly.Points = append([]model.Vector3{segments[j].Start}, poly.Points...)
used[j] = true
foundLink = true
break
}
}
}
// Close the polygon if applicable
if len(poly.Points) > 2 {
lastPt := poly.Points[len(poly.Points)-1]
firstPt := poly.Points[0]
if math.Abs(lastPt.X-firstPt.X) < 0.001 && math.Abs(lastPt.Y-firstPt.Y) < 0.001 {
poly.Points = poly.Points[:len(poly.Points)-1] // Remove duplicate end
poly.IsClosed = true
}
}
if len(poly.Points) > 2 {
polygons = append(polygons, poly)
}
}
return polygons
}
// Root Spacing for Tree Support
// rootSpacingMM is now only used functionally if branches merge. Raw targeting uses SDF gravity.
rootSpacingMM := config.SupportTreeTrunkDiameter
_ = rootSpacingMM
// Keep track of which tree slices are "interface" layers
// and need solid infill
interfaceGrids := make([]*Grid, len(bm.Slices))
for i := range interfaceGrids {
interfaceGrids[i] = NewGrid(bounds, resolution)
}
// Taper parameters
// Taper angle: 1.5 degrees is standard for smooth organic trunks
taperDist := config.LayerHeight * math.Tan(1.5*math.Pi/180.0)
taperStepsPerLayer := taperDist / resolution
// Spread roots based on configured trunk diameter
// Removed global Target Roots as they aggressively pull branches sideways.
// We will rely on downward vertical gravity and natural overlapping for trunk formation.
// Iterate from top to bottom
for i := len(bm.Slices) - 1; i >= 1; i-- {
currentModel := layerGrids[i]
lowerModel := layerGrids[i-1]
targetSupport := supportGrids[i-1]
// Precompute Distance Field for the lower model
// This provides mathematically continuous, sub-pixel accurate distances for overhang detection!
lowerSDF := lowerModel.DistanceField()
if config.SupportType == model.SupportTree {
// --- SKELETON TREE LOGIC ---
// Identify New Overhangs (Islands)
// Using the user's specified SupportAngle limit
overhangGrid := NewGrid(currentModel.GetBounds(), resolution)
hasOverhangs := false
for idx, val := range currentModel.Cells {
// Require support if current model has a pixel where lower model's distance exceeds the overhang limit
if val && lowerSDF[idx] > overhangDistSteps {
overhangGrid.Cells[idx] = true
hasOverhangs = true
}
}
// We rely on the `overhangDistSteps` buffer to handle micro-details natively.
// Spawn New Branches from Overhang Areas (Dense placement)
if hasOverhangs {
// Organic supports need perfectly controlled density at the canopy to avoid wasting material.
// We use the `SupportDensity` parameter to compute exactly how far apart the branch tips
// should be spaced to achieve the requested Area Coverage Percentage.
// Area of tip = Pi * R^2. Area of grid cell = S^2.
// Density = (Pi * R^2) / S^2 => S = R * math.Sqrt(Pi / Density)
radiusMM := config.SupportTreeBranchDiameter / 2.0
density := config.SupportDensity
if density < 0.02 {
density = 0.02 // Prevent infinite sparse spacing (2% min)
} else if density > 1.0 {
density = 1.0
}
tipSpacingMM := radiusMM * math.Sqrt(math.Pi/density)
tipSpacingSteps := int(math.Ceil(tipSpacingMM / resolution))
if tipSpacingSteps < 1 {
tipSpacingSteps = 1
}
newBranchesSpawned := 0
for gy := 0; gy < overhangGrid.Height; gy += tipSpacingSteps {
for gx := 0; gx < overhangGrid.Width; gx += tipSpacingSteps {
if overhangGrid.Get(gx, gy) {
// Hard cap to prevent physics explosions / OOM if parameters are extreme
// However, taking the cap off now that performance is fixed lets large prints actually be fully supported.
if newBranchesSpawned > 2000 {
// We must break out of BOTH loops
gy = overhangGrid.Height
break
}
// Add new fine branch
activeBranches = append(activeBranches, &Branch{
X: float64(gx), Y: float64(gy),
Radius: (config.SupportTreeBranchDiameter / 2.0) / resolution,
Age: 0,
Weight: 1, // Single tip
Dead: false,
})
newBranchesSpawned++
}
}
}
}
// Move Branches Towards Roots
nextBranches := make([]*Branch, 0, len(activeBranches))
// Minimum 0.4mm gap
xyGapSteps := int(math.Ceil(math.Max(0.4, config.SupportXYGap) / resolution))
// SDF for Gravity Repulsion is already calculated as lowerSDF
sdf := lowerSDF
for _, b := range activeBranches {
// 1. Dynamic Organic Slope Profiling
// Strict Lean Angle Constraint
// The horizontal shift towards the barycenter is strictly capped by the maximum
// tree branch lean angle (e.g., SupportAngle).
maxAngle := config.SupportAngle
if maxAngle > 50.0 {
maxAngle = 50.0 // Hard structural limit for Y-junction stability
}
treeMaxShiftDist := config.LayerHeight * math.Tan(maxAngle*math.Pi/180.0)
treeMaxShiftSteps := treeMaxShiftDist / resolution
// Desired Target Clearance
// Safe distance = Branch radius + spacing gap
targetClearance := b.Radius + float64(xyGapSteps)
// Bilinear interpolation for smooth SDF and gradients (Fixes Stepping)
bx := b.X
by := b.Y
gridX := int(bx)
gridY := int(by)
fx := bx - float64(gridX)
fy := by - float64(gridY)
getSDF := func(gx, gy int) float64 {
if gx < 0 {
gx = 0
}
if gy < 0 {
gy = 0
}
if gx >= lowerModel.Width {
gx = lowerModel.Width - 1
}
if gy >= lowerModel.Height {
gy = lowerModel.Height - 1
}
return sdf[gy*lowerModel.Width+gx]
}
s00 := getSDF(gridX, gridY)
s10 := getSDF(gridX+1, gridY)
s01 := getSDF(gridX, gridY+1)
s11 := getSDF(gridX+1, gridY+1)
currentSDF := s00*(1.0-fx)*(1.0-fy) + s10*fx*(1.0-fy) + s01*(1.0-fx)*fy + s11*fx*fy
// True Floor Detection: Did we land on a flat surface?
zGapLayers := int(math.Ceil(config.SupportZGap / config.LayerHeight))
if zGapLayers < 1 {
zGapLayers = 1
}
hitFloor := false
imminentFloor := false // True if a floor is approaching within safety margin
checkFloor := func(gap int) bool {
cLayer := i - gap
if cLayer >= 0 && cLayer < len(layerGrids) {
fModel := layerGrids[cLayer]
if gridX >= 0 && gridX < fModel.Width && gridY >= 0 && gridY < fModel.Height {
// ONLY check the exact center pixel.
// SDF repulsion already keeps the branch center away from vertical walls.
// If the center itself hits something straight down, it MUST be a flat floor!
if fModel.Cells[gridY*fModel.Width+gridX] {
return true
}
}
}
return false
}
hitFloor = checkFloor(zGapLayers)
if !hitFloor {
imminentFloor = checkFloor(zGapLayers + 2)
}
// If we reached the absolute bottom layer, it's the build plate.
hitBuildPlate := (i <= 1)
// As soon as the branch touches a True Floor (or build plate), it must instantly terminate.
if hitFloor || hitBuildPlate {
if config.SupportPlacement == model.SupportEverywhere || hitBuildPlate {
// Firmly plant our final footing into the model/bed
targetSupport.SetDiskFloat(b.X, b.Y, b.Radius)
}
continue // Branch terminates here
}
// SDF Gradient Calculation (Bilinear)
gradX := (s10-s00)*(1.0-fy) + (s11-s01)*fy
gradY := (s01-s00)*(1.0-fx) + (s11-s10)*fx
gradMag := math.Sqrt(gradX*gradX + gradY*gradY)
if gradMag > 0.001 {
gradX /= gradMag
gradY /= gradMag
}
// The Distance from the model surface
penetration := targetClearance - currentSDF
// 1. Local Barycenter Clustering (LBTS Method)
// Group nearby branches and find their Center of Mass (Barycenter).
// We use a search radius based on trunk diameter to encourage distinct trunk formation.
searchRadius := (config.SupportTreeTrunkDiameter * 1.5) / resolution
Bx, By := b.X, b.Y
weight := 1.0 // Self weight
for _, neighbor := range activeBranches {
if neighbor == b {
continue
}
ndx := neighbor.X - b.X
ndy := neighbor.Y - b.Y
ndist := math.Sqrt(ndx*ndx + ndy*ndy)
if ndist < searchRadius {
Bx += neighbor.X
By += neighbor.Y
weight += 1.0
}
}
Bx /= weight
By /= weight
// Move towards Local Barycenter
targetX := Bx - b.X
targetY := By - b.Y
targetDist := math.Sqrt(targetX*targetX + targetY*targetY)
if targetDist > 0 {
// Pull towards barycenter (acceleration)
pullForce := targetDist * 0.10 // 10% pull per layer
b.VX += (targetX / targetDist) * pullForce
b.VY += (targetY / targetDist) * pullForce
}
// 2. Collision Avoidance & Organic Flaring (DVIST via SDF)
// If a branch gets near the model, it shouldn't just slide down the surface (hugging).
// It should gracefully accelerate away into open space.
avoidanceZone := targetClearance + (5.0 / resolution) // 5mm warning zone
if currentSDF < avoidanceZone && !imminentFloor {
if currentSDF <= targetClearance {
// Hard position correction (don't let it inside)
if penetration > 0 {
b.X += gradX * penetration
b.Y += gradY * penetration
// And bounce its velocity outwards so it doesn't immediately fall back in
b.VX += gradX * penetration * 0.5
b.VY += gradY * penetration * 0.5
}
} else {
// Soft avoidance factor (1.0 near model, 0.0 at outer bound)
factor := 1.0 - ((currentSDF - targetClearance) / (5.0 / resolution))
// Exponential curve creates a smooth flare
flareForce := factor * factor * (1.0 / resolution) // 1.0mm/layer accel
b.VX += gradX * flareForce
b.VY += gradY * flareForce
}
}
// Apply particle physics!
// Damping (friction) naturally straightens branches to vertical
// when no forces (pull or avoidance) are acting on them.
b.VX *= 0.60
b.VY *= 0.60
// Fatter trunks (lower in the tree) should be structurally straighter (more vertical).
// They carry far more mathematical "Weight" because many tips have merged into them.
// A huge trunk (Weight 10+) shouldn't zig-zag like a tiny canopy tip.
flexibility := 1.0
if b.Weight > 1 {
// Exponential decay of flexibility as the trunk gets heavier.
flexibility = 1.0 / float64(b.Weight)
}
if flexibility < 0.15 {
flexibility = 0.15 // Guarantee at least a 15% crawl speed to clear obstacles
}
dynamicMaxShift := treeMaxShiftSteps * flexibility
// Limit scalar speed to the Dynamic Maximum Structural Angle
speed := math.Sqrt(b.VX*b.VX + b.VY*b.VY)
if speed > dynamicMaxShift {
b.VX = (b.VX / speed) * dynamicMaxShift
b.VY = (b.VY / speed) * dynamicMaxShift
}
// Update position
b.X += b.VX
b.Y += b.VY
// Ensure we didn't get pushed out of bounds
if b.X < 0 {
b.X = 0
}
if b.Y < 0 {
b.Y = 0
}
if b.X > float64(lowerModel.Width-1) {
b.X = float64(lowerModel.Width - 1)
}
if b.Y > float64(lowerModel.Height-1) {
b.Y = float64(lowerModel.Height - 1)
}
// Keep growing downwards
b.Age++
// Natural Organic Tapering: Branches thicken as they drop and merge.
// We calculate the maximum Target Radius based on mathematical Weight + continuous Age taper.
baseRad := ((config.SupportTreeBranchDiameter / 2.0) / resolution) + (float64(b.Age) * taperStepsPerLayer)
maxTrunkRad := (config.SupportTreeTrunkDiameter / 2.0) / resolution
targetRad := math.Sqrt(float64(b.Weight)) * baseRad
if targetRad > maxTrunkRad {
targetRad = maxTrunkRad
}
// Flare smoothly towards the target radius instead of snapping (fixes Y-Junction stepping)
if b.Radius < targetRad {
b.Radius += (targetRad - b.Radius) * 0.10 // 10% smoothing per layer yields beautiful flares
}
if b.Age > 0 && imminentFloor {
// We successfully found a floor to land on!
// Mark it as dead so it renders its base here, but won't fall down into the gap/model next layer.
b.Dead = true
nextBranches = append(nextBranches, b)
continue
}
nextBranches = append(nextBranches, b)
}
// Merge Nearby Branches (Y-Junctions)
mergedBranches := make([]*Branch, 0)
alreadyMerged := make(map[*Branch]bool)
for j, b1 := range nextBranches {
if alreadyMerged[b1] {
continue
}
// Start a group with b1
groupX, groupY := b1.X, b1.Y
groupVX, groupVY := b1.VX, b1.VY
groupCount := 1.0
groupWeight := b1.Weight
maxRadius := b1.Radius
for k := j + 1; k < len(nextBranches); k++ {
b2 := nextBranches[k]
if alreadyMerged[b2] {
continue
}
ddx := b1.X - b2.X
ddy := b1.Y - b2.Y
dist := math.Sqrt(ddx*ddx + ddy*ddy)
// We gracefully wait to logically merge branches until they have almost perfectly converged
// to identical coordinates. Doing this prevents instantaneous positional teleportation.
// The Marching Squares naturally traces them as a beautifully smooth unified trunk
// long before they are culled.
mergeThreshold := 0.2 / resolution // 0.2mm (basically overlapping)
if dist < mergeThreshold {
// Merge b2 into b1
groupX += b2.X
groupY += b2.Y
groupVX += b2.VX
groupVY += b2.VY
groupCount++
groupWeight += b2.Weight
if b2.Radius > maxRadius {
maxRadius = b2.Radius
}
alreadyMerged[b2] = true
}
}
if groupCount > 0 {
// We take the Maximum Radius of the merging group instead of preserving volume (Sqrt(SumArea)).
// Because branches naturally taper (thicken) as they drop, taking the Max Radius guarantees
// a perfectly continuous outer shell without any violent horizontal steps-outwards!
maxTrunkRadius := (config.SupportTreeTrunkDiameter / 2.0) / resolution
if maxRadius > maxTrunkRadius {
maxRadius = maxTrunkRadius
}
mergedBranches = append(mergedBranches, &Branch{
X: groupX / groupCount,
Y: groupY / groupCount,
VX: groupVX / groupCount,
VY: groupVY / groupCount,
Radius: maxRadius, // Continues to flare naturally based on new Weight
Age: b1.Age,
Weight: groupWeight,
Dead: b1.Dead, // If any branch in the group is dead, the merged branch is dead
})
}
}
activeBranches = mergedBranches
// Render smoothly using floating point representation
for _, b := range activeBranches {
targetSupport.SetDiskFloat(b.X, b.Y, b.Radius)
}
// Save for Metaball extraction later
treeBranchesByLayer[i-1] = append([]*Branch(nil), activeBranches...)
if i%50 == 0 || i < 10 {
fmt.Printf("Layer %d Output: %d viable branches\\n", i, len(activeBranches))
}
// --- CULL DEAD BRANCHES BEFORE NEXT LAYER ---
// Any branch that hit a floor was marked Dead. It rendered on *this* layer,
// but we must remove it now so it doesn't simulate downward through the model.
aliveBranches := make([]*Branch, 0, len(activeBranches))
for _, b := range activeBranches {
if !b.Dead {
aliveBranches = append(aliveBranches, b)
}
}
activeBranches = aliveBranches
// If we are within SupportInterfaceLayers of an overhang,
// mark this entire support region as an interface that needs solid fill
if hasOverhangs {
for interfaceLayer := 0; interfaceLayer < config.SupportInterfaceLayers; interfaceLayer++ {
idxToMark := i - interfaceLayer
if idxToMark >= 0 {
// Copy current overhang area to the interface grid for that layer
for cellIdx, isOverhang := range overhangGrid.Cells {
if isOverhang {
// We Dilate the overhang a bit so the interface fill fully bridges the gap
// (handled roughly by just assigning it directly to the interfaceGrid and filling trees that overlap it)
interfaceGrids[idxToMark].Cells[cellIdx] = true
}
}
}
}
}
} else {
// --- LINEAR/GRID LOGIC ---
upperSupport := supportGrids[i]
// Copy upper support down
for idx, needed := range upperSupport.Cells {
if needed {
targetSupport.Cells[idx] = true
}
}
// Add new overhangs using the exact Distance Field (much more accurate than Dilation)
for idx, occupied := range currentModel.Cells {
if occupied && lowerSDF[idx] > overhangDistSteps {
targetSupport.Cells[idx] = true
}
}
}
// Subtract Model at this layer (ensure interface separation)
xyGapSteps := int(math.Ceil(config.SupportXYGap / resolution))
if config.SupportType == model.SupportTree {
// Add 1 extra voxel buffer specifically before Chaikin smoothing
// to ensure the rounded corner-cuts never pierce the model surface
xyGapSteps += 1
}
modelMask := lowerModel
if xyGapSteps > 0 {
modelMask = lowerModel.Dilate(xyGapSteps)
}
// Only subtract if it's traditional support OR if it's a higher layer of tree support.
// For tree supports, the physics engine already guarantees exact clearance using SDF Repulsion!
// If we use the raw bitmap subtraction here, trunks dropping over sloped roofs get their
// cross-section violently chewed up and deleted. We ONLY use this for Grid/Line supports!
if config.SupportType != model.SupportTree {
for idx, isModel := range modelMask.Cells {
if isModel {
targetSupport.Cells[idx] = false
}
}
}
}
// 3. Generate Paths from SupportGrids
paths := make(map[int][]model.ContinuousPath)
for i, grid := range supportGrids {
var layerPaths []model.ContinuousPath
z := bm.Slices[i].Z
if config.SupportType == model.SupportTree {
// Tree Supports: Generate ONE strong outer shell using Metaballs.
// (Previous attempts to simulate inner walls by raising the metaball threshold caused dense canopies
// to rip apart into hundreds of microscopic isolated internal circles, ballooning the JSON config to 3.2GB).
shellsToGenerate := 1
// Generate Perimeters based on floating point Metaballs
var contours []model.Polygon
// Trace the exact contours using Marching Squares
layerActiveBranches := treeBranchesByLayer[i]
contours = ExtractBranchContours(layerActiveBranches, resolution, resolution, 1.0, layerGrids[i].GetBounds())
for shell := 0; shell < shellsToGenerate; shell++ {
shellContours := contours
for _, poly := range shellContours {
points := poly.Points
if len(points) < 3 {
// If a polygon has fewer than 3 points, it cannot form a closed loop.
// The original code had a 'continue' here, but the instruction implies
// we should still process it if it's part of the totalPointsPerLayer count.
// However, to maintain the original logic of not processing invalid polygons for segments,
// we will keep the continue here.
continue
}
var decimated []model.Vector3
if len(points) > 0 {
decimated = append(decimated, points[0])
for k := 1; k < len(points)-1; k++ {
pPrev := decimated[len(decimated)-1]
pCurr := points[k]
pNext := points[k+1]
// Calculate cross product of (pCurr-pPrev) and (pNext-pCurr)
dx1 := pCurr.X - pPrev.X
dy1 := pCurr.Y - pPrev.Y
dx2 := pNext.X - pCurr.X
dy2 := pNext.Y - pCurr.Y
cross := dx1*dy2 - dy1*dx2
// If points are perfectly straight, skip adding pCurr
if math.Abs(cross) > 0.005 {
decimated = append(decimated, pCurr)
}
}
decimated = append(decimated, points[len(points)-1])
}
points = decimated
// The global JSON bloat bug was fixed in the exporter, so we have infinite
// performance headroom to restore organic curve smoothing to the trunks.
points = smoothPath(points, 2)
poly := model.Polygon{Points: points, IsClosed: true}
poly.SetZ(z)
if path := poly.ToContinuousPath(config.SupportSpeed, model.CategorySupport, i); len(path.Segments) > 0 {
layerPaths = append(layerPaths, path)
}
}
}
// Generate Dense Interface Infill inside the innermost shell
// If this layer was marked as an interface layer, we fill it.
interfaceGrid := interfaceGrids[i]
needsFill := false
for _, isInterface := range interfaceGrid.Cells {
if isInterface {
needsFill = true
break
}
}
if needsFill {
// The area we fill is the intersection of the innermost branch shell and the interface region
fillArea := grid.Erode(shellsToGenerate) // Step inward once more so infill binds inside the wall
// Pre-calculate dilation OUTSIDE the loop to prevent massive performance hang
dilatedInterface := interfaceGrid.Dilate(3)
// Generate dense Zig-Zag lines
// We use the same raster technique but strictly inside the fillArea
for j := 0; j < fillArea.Height; j += 2 { // Dense spacing (every 2 grid cells)
y := fillArea.MinY + float64(j)*fillArea.Resolution + fillArea.Resolution/2
var startX int = -1
for x := 0; x < fillArea.Width; x++ {
// Only fill if it's inside the tree branch AND it's marked as an interface zone
active := fillArea.Get(x, j) && (interfaceGrid.Get(x, j) || dilatedInterface.Get(x, j))
if active {
if startX == -1 {
startX = x
}
} else {
if startX != -1 {
// End of segment
pStart := model.Vector3{
X: fillArea.MinX + float64(startX)*fillArea.Resolution,
Y: y,
Z: z,
}
pEnd := model.Vector3{
X: fillArea.MinX + float64(x)*fillArea.Resolution,
Y: y,
Z: z,
}
layerPaths = append(layerPaths, model.ContinuousPath{
Segments: []model.PathSegment{{
Start: pStart,
End: pEnd,
Speed: config.SupportSpeed,
Category: model.CategorySupport,
}},
PathType: model.PathExtrusion,
LayerIndex: i,
})
startX = -1
}
}
}
if startX != -1 {
pStart := model.Vector3{
X: fillArea.MinX + float64(startX)*fillArea.Resolution,
Y: y,
Z: z,
}
pEnd := model.Vector3{
X: fillArea.MinX + float64(fillArea.Width-1)*fillArea.Resolution,
Y: y,
Z: z,
}
layerPaths = append(layerPaths, model.ContinuousPath{
Segments: []model.PathSegment{{
Start: pStart,
End: pEnd,
Speed: config.SupportSpeed,
Category: model.CategorySupport,
}},
PathType: model.PathExtrusion,
LayerIndex: i,
})
}
}
}
} else {
// Linear filling (zig-zag / raster)
for j := 0; j < grid.Height; j++ {
y := grid.MinY + float64(j)*grid.Resolution + grid.Resolution/2
var startX int = -1
for x := 0; x < grid.Width; x++ {
active := grid.Get(x, j)
if active {
if startX == -1 {
startX = x
}
} else {
if startX != -1 {
// End of segment
pStart := model.Vector3{
X: grid.MinX + float64(startX)*grid.Resolution,
Y: y,
Z: z,
}
pEnd := model.Vector3{
X: grid.MinX + float64(x)*grid.Resolution,
Y: y,
Z: z,
}
// Create segment
seg := model.PathSegment{
Start: pStart,
End: pEnd,
Speed: config.SupportSpeed,
Category: model.CategorySupport,
}
layerPaths = append(layerPaths, model.ContinuousPath{
Segments: []model.PathSegment{seg},
PathType: model.PathExtrusion,
LayerIndex: i,
})
startX = -1
}
}
}
// End of row check
if startX != -1 {
pStart := model.Vector3{
X: grid.MinX + float64(startX)*grid.Resolution,
Y: y,
Z: z,
}
pEnd := model.Vector3{
X: grid.MinX + float64(grid.Width)*grid.Resolution,
Y: y,
Z: z,
}
seg := model.PathSegment{
Start: pStart,
End: pEnd,
Speed: config.SupportSpeed,
Category: model.CategorySupport,
}
layerPaths = append(layerPaths, model.ContinuousPath{
Segments: []model.PathSegment{seg},
PathType: model.PathExtrusion,
LayerIndex: i,
})
}
}
}
if len(layerPaths) > 0 {
paths[i] = layerPaths
}
}
return paths
}
package core
import (
"math"
"github.com/siherrmann/slicer/model"
)
// CalaculateWallOffset calculates the offset distance for a given wall index based on line width and overlap.
func CalculateWallOffset(index int, params model.SliceConfig) float64 {
if index <= 0 {
return params.LineWidth * 0.5
} else {
return (params.LineWidth * 0.5) + (params.LineWidth * params.InfillOverlap)
}
}
// CalculateWallCount calculates how many wall lines are needed.
// It prioritizes ShellCount if it's greater than 0, otherwise it uses WallThickness.
func CalculateWallCount(params model.SliceConfig) int {
if params.ShellCount > 0 {
return params.ShellCount
}
return int(math.Max(1, math.Ceil(params.WallThickness/params.LineWidth)))
}
// CalculateInfillOffset calculates the offset distance for the infill area based on the number of walls and line width.
func CalculateInfillOffset(params model.SliceConfig) float64 {
return CalculateWallOffset(CalculateWallCount(params), params)
}
// GenerateWalls generates the wall paths for a given shell polygon based on the slicing configuration.
func GenerateWalls(shell model.Polygon, params model.SliceConfig, layerIndex int) []model.ContinuousPath {
wallCount := CalculateWallCount(params)
wallPaths := make([]model.ContinuousPath, 0, wallCount)
for i := range wallCount {
offsetDist := CalculateWallOffset(i, params)
wall := shell.OffsetPolygon(-offsetDist)
if wall != nil && len(wall.Points) > 2 {
// Determine if this is the outermost wall
isOuterWall := (i == 0)
// Set speed based on wall position
speed := params.WallSpeed
category := model.CategoryInnerWall
if isOuterWall {
speed = params.OuterShellSpeed
category = model.CategoryOuterWall
}
// Create a continuous path for this wall (closed loop)
wall.IsClosed = true // Offset polygons are inherently closed
wallPaths = append(wallPaths, wall.ToContinuousPath(speed, category, layerIndex))
}
}
// Apply shell order: reverse if outside-in (outer wall first)
if params.ShellOrder == model.ShellOutsideIn && len(wallPaths) > 1 {
for i, j := 0, len(wallPaths)-1; i < j; i, j = i+1, j-1 {
wallPaths[i], wallPaths[j] = wallPaths[j], wallPaths[i]
}
}
return wallPaths
}
package main
import (
"fmt"
"log"
"os"
"github.com/siherrmann/slicer"
"github.com/siherrmann/slicer/model"
)
func main() {
s := slicer.NewSlicer()
file, err := os.Open("../af86a550_f42a3fa6_rpi-3-case.stl")
if err != nil {
log.Fatal(err)
}
defer file.Close()
stlModel, err := s.LoadSTLModel(file, "rpi-3-case.stl")
if err != nil {
log.Fatal(err)
}
bounds := stlModel.GetBounds()
fmt.Printf("Bounds: Min(%.2f, %.2f, %.2f) Max(%.2f, %.2f, %.2f)\n",
bounds.MinX, bounds.MinY, bounds.MinZ, bounds.MaxX, bounds.MaxY, bounds.MaxZ)
fmt.Printf("Size: X: %.2f Y: %.2f Z: %.2f\n",
bounds.MaxX-bounds.MinX, bounds.MaxY-bounds.MinY, bounds.MaxZ-bounds.MinZ)
paths, err := s.GeneratePrintPaths(s.Config)
if err != nil {
log.Fatal(err)
}
var totalResult model.PrintResult
var totalWalls, totalInfill, totalSupport float64
for _, p := range paths {
r := p.GetPrintResult(s.Config)
totalResult.PrintTime += r.PrintTime
totalResult.ExtrusionPathLength += r.ExtrusionPathLength
totalResult.TravelPathLength += r.TravelPathLength
for _, seg := range p.Segments {
if seg.IsTravel {
continue
}
len := seg.Start.Distance(seg.End)
switch seg.Category {
case model.CategoryInnerWall, model.CategoryOuterWall:
totalWalls += len
case model.CategoryInfill, model.CategorySolidInfill:
totalInfill += len
case model.CategorySupport:
totalSupport += len
}
}
}
fmt.Printf("Print Time: %.2f seconds (%.2f hours)\n", totalResult.PrintTime, totalResult.PrintTime/3600)
fmt.Printf("Extrusion Length: %.2f mm\n", totalResult.ExtrusionPathLength)
fmt.Printf("Travel Length: %.2f mm\n", totalResult.TravelPathLength)
fmt.Printf("Walls: %.2f mm\n", totalWalls)
fmt.Printf("Infill: %.2f mm\n", totalInfill)
fmt.Printf("Support: %.2f mm\n", totalSupport)
}
package main
import (
"log"
"os"
"github.com/siherrmann/slicer"
)
// DISCLAIMER: The examples are from https://ozeki.hu/p_1116-sample-stl-files-you-can-use-for-testing.html
func main() {
slicer := slicer.NewSlicer()
file, err := os.Open("./Eiffel_tower_sample.STL")
if err != nil {
panic(err)
}
defer file.Close()
stl, err := slicer.LoadSTLModel(file, "Eiffel_tower_sample.STL")
if err != nil {
panic(err)
}
log.Printf("Model statistics:")
// #nosec G706
log.Printf(" - Total triangles: %d", stl.GetTriangleCount())
// #nosec G706
log.Printf(" - Surface area: %.2f", stl.GetSurfaceArea())
// #nosec G706
log.Printf(" - Volume: %.2f", stl.GetVolume())
bounds := stl.GetBounds()
// #nosec G706
log.Printf(" - Bounds: minX=%v, maxX=%v", bounds.MinX, bounds.MaxX)
log.Println("")
// Use a small tolerance appropriate for STL precision (0.001mm = 1 micron)
// This should match vertices that differ only due to floating point errors
tolerance := 0.00001
issues := stl.Validate(tolerance)
if !issues.IsWatertight || len(issues.BoundaryEdges) > 0 || len(issues.NonManifoldEdges) > 0 || len(issues.DegenerateTriangles) > 0 || len(issues.InvalidTriangles) > 0 || len(issues.DuplicateTriangles) > 0 {
log.Printf("Issue summary:")
// #nosec G706
log.Printf(" - Boundary edges (holes): %d", len(issues.BoundaryEdges))
// #nosec G706
log.Printf(" - Non-manifold edges: %d", len(issues.NonManifoldEdges))
// #nosec G706
log.Printf(" - Degenerate triangles: %d", len(issues.DegenerateTriangles))
// #nosec G706
log.Printf(" - Invalid normals: %d", len(issues.InvalidTriangles))
// #nosec G706
log.Printf(" - Duplicate triangles: %d", len(issues.DuplicateTriangles))
log.Println("")
log.Printf("⚠️ Model has issues but will attempt to slice anyway...")
log.Println("")
} else {
log.Printf("✅ The model is watertight and has no issues!")
log.Println("")
}
// Perform slicing
log.Printf("Slicing model...")
log.Printf(" - Layer height: %.2f mm", slicer.Config.LayerHeight)
log.Printf(" - First layer: %.2f mm", slicer.Config.FirstLayer)
slices, err := slicer.Slice(stl)
if err != nil {
panic(err)
}
log.Printf("✅ Slicing complete!")
// #nosec G706
log.Printf(" - Total layers: %d", len(slices))
// #nosec G706
log.Printf(" - Height range: %.2f mm to %.2f mm", slices[0].Z, slices[len(slices)-1].Z)
log.Println("")
// Show statistics for a few layers
log.Printf("Layer statistics:")
layersToShow := []int{0, len(slices) / 4, len(slices) / 2, 3 * len(slices) / 4, len(slices) - 1}
for _, idx := range layersToShow {
if idx >= 0 && idx < len(slices) {
slice := slices[idx]
// #nosec G706
log.Printf(" Layer %d (Z=%.2f mm): %d segments, %d contours",
idx, slice.Z, len(slice.Segments), len(slice.Polygons))
}
}
}
package main
import (
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/siherrmann/slicer/handler"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
h := handler.NewViewerHandler()
r.Get("/", h.HandleView)
r.Post("/slice", h.HandleSlice)
r.Post("/export", h.HandleExport)
log.Println("Starting server on http://localhost:4000...")
server := &http.Server{
Addr: ":4000",
Handler: r,
ReadTimeout: 5 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}
package handler
import (
"fmt"
"net/http"
"os"
"strconv"
"github.com/siherrmann/slicer"
"github.com/siherrmann/slicer/core"
"github.com/siherrmann/slicer/model"
"github.com/siherrmann/slicer/view"
)
type ViewerHandler struct {
Slicer *slicer.Slicer
LastPaths []model.ContinuousPath // Cache paths for export
}
func NewViewerHandler() *ViewerHandler {
return &ViewerHandler{
Slicer: slicer.NewSlicer(),
}
}
func (h *ViewerHandler) HandleView(w http.ResponseWriter, r *http.Request) {
// Load default model for demo
file, err := os.Open("./example/Stanford_Bunny_sample.stl")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to open model file: %v", err), http.StatusInternalServerError)
return
}
defer file.Close()
bm, err := h.Slicer.LoadSTLModel(file, "Stanford_Bunny_sample.stl")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load model: %v", err), http.StatusInternalServerError)
return
}
h.Slicer.Model = bm.CleanSize().CleanPosition().CleanBottom(1).CleanPosition()
// Default slicing for initial view
paths, err := h.Slicer.GeneratePrintPaths(h.Slicer.Config)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate paths: %v", err), http.StatusInternalServerError)
return
}
h.LastPaths = paths
component := view.STLViewer(h.Slicer.Model, paths, h.Slicer.Config)
_ = component.Render(r.Context(), w)
}
func (h *ViewerHandler) HandleSlice(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
// Ensure model is loaded (e.g. if server restarted but page is open)
if h.Slicer.Model == nil {
file, err := os.Open("./example/Stanford_Bunny_sample.stl")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to open default model file: %v", err), http.StatusInternalServerError)
return
}
defer file.Close()
bm, err := h.Slicer.LoadSTLModel(file, "Stanford_Bunny_sample.stl")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load default model: %v", err), http.StatusInternalServerError)
return
}
// Clean the model just like in HandleView
h.Slicer.Model = bm.CleanSize().CleanPosition().CleanBottom(1).CleanPosition()
}
// Update config from form values
config := h.Slicer.Config
// Float64 parsing helper
pf := func(key string, target *float64) {
if val := r.FormValue(key); val != "" {
if f, err := strconv.ParseFloat(val, 64); err == nil {
*target = f
}
}
}
// Int parsing helper
pi := func(key string, target *int) {
if val := r.FormValue(key); val != "" {
if i, err := strconv.Atoi(val); err == nil {
*target = i
}
}
}
// Layer & Shell settings
pf("layer_height", &config.LayerHeight)
pf("first_layer", &config.FirstLayer)
pi("shell_count", &config.ShellCount)
pf("line_width", &config.LineWidth)
// Shell order
if val := r.FormValue("shell_order"); val != "" {
if i, err := strconv.Atoi(val); err == nil {
config.ShellOrder = model.ShellOrder(i)
}
}
// Infill settings
pf("infill_density", &config.InfillDensity)
pf("infill_angle", &config.InfillAngle)
if val := r.FormValue("infill_type"); val != "" {
if i, err := strconv.Atoi(val); err == nil {
config.InfillType = model.InfillType(i)
}
}
// Speed settings
pf("infill_speed", &config.InfillSpeed)
pf("wall_speed", &config.WallSpeed)
pf("outer_shell_speed", &config.OuterShellSpeed)
pf("travel_speed", &config.TravelSpeed)
pf("first_layer_speed", &config.FirstLayerSpeed)
pf("min_speed", &config.MinSpeed)
// Extrusion
pf("flow_multiplier", &config.FlowMultiplier)
// Temperature & Cooling
pf("nozzle_temp", &config.NozzleTemp)
pf("bed_temp", &config.BedTemp)
pi("fan_speed", &config.FanSpeed)
pi("fan_on_layer", &config.FanOnLayer)
pf("min_layer_time", &config.MinLayerTime)
// Retraction
pf("retraction_dist", &config.RetractionDist)
pf("retraction_speed", &config.RetractionSpeed)
pf("retraction_zhop", &config.RetractionZHop)
pf("retraction_prime", &config.RetractionPrime)
// Machine settings
pf("nozzle_diameter", &config.NozzleDiameter)
// G-code settings
if val := r.FormValue("gcode_flavor"); val != "" {
if i, err := strconv.Atoi(val); err == nil {
config.GCodeFlavor = model.GCodeFlavor(i)
}
}
if val := r.FormValue("start_gcode"); val != "" {
config.StartGCode = val
}
if val := r.FormValue("end_gcode"); val != "" {
config.EndGCode = val
}
// Skirt/Brim
pi("skirt_count", &config.SkirtCount)
pf("skirt_offset", &config.SkirtOffset)
pi("brim_count", &config.BrimCount)
// Support settings
if val := r.FormValue("support_type"); val != "" {
if i, err := strconv.Atoi(val); err == nil {
config.SupportType = model.SupportType(i)
}
}
pf("support_density", &config.SupportDensity)
pf("support_angle", &config.SupportAngle)
pf("support_z_gap", &config.SupportZGap)
pf("support_xy_gap", &config.SupportXYGap)
pf("support_speed", &config.SupportSpeed)
pf("support_tree_branch_diameter", &config.SupportTreeBranchDiameter)
pf("support_tree_trunk_diameter", &config.SupportTreeTrunkDiameter)
// Raft settings
pi("raft_layers", &config.RaftLayers)
pf("raft_offset", &config.RaftOffset)
// Vase mode (checkbox: value="true" when checked, absent when not)
config.VaseMode = r.FormValue("vase_mode") == "true"
// Build volume
pf("build_volume_x", &config.BuildVolumeX)
pf("build_volume_y", &config.BuildVolumeY)
pf("build_volume_z", &config.BuildVolumeZ)
// Clear previous slices so re-slicing uses the new config
h.Slicer.Model.Slices = nil
// Regenerate paths
paths, err := h.Slicer.GeneratePrintPaths(config)
if err != nil {
http.Error(w, fmt.Sprintf("Slicing failed: %v", err), http.StatusInternalServerError)
return
}
h.LastPaths = paths
// Render only the sidebar form + script tags — canvas is untouched
component := view.SlicerSidebar(h.Slicer.Model, paths, config)
_ = component.Render(r.Context(), w)
}
// HandleExport generates G-code from the last sliced paths and returns it as a downloadable file.
func (h *ViewerHandler) HandleExport(w http.ResponseWriter, r *http.Request) {
if len(h.LastPaths) == 0 {
http.Error(w, "No sliced model available. Please slice first.", http.StatusBadRequest)
return
}
gcode := core.GenerateGCode(h.LastPaths, *h.Slicer.Config)
// Set headers for file download
filename := "print.gcode"
if h.Slicer.Model != nil {
filename = h.Slicer.Model.Name + ".gcode"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(gcode)))
_, _ = w.Write([]byte(gcode))
}
package helper
import "strconv"
func ParseFloat32(s string) (float32, error) {
f64, err := strconv.ParseFloat(s, 32)
return float32(f64), err
}
func ParseFloat64(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}
package model
import (
"math"
)
// BaseModel contains common data and methods for 3D models (STL, 3MF, etc.)
type BaseModel struct {
Name string `json:"name"`
SliceConfig *SliceConfig `json:"slice_config"`
Triangles []Triangle `json:"triangles"`
Slices []*Slice `json:"slices"`
Bounds BoundingBox `json:"bounds"`
}
// NewBaseModel creates a new empty base model
func NewBaseModel(name string) *BaseModel {
return &BaseModel{
Name: name,
Triangles: make([]Triangle, 0),
Bounds: BoundingBox{},
}
}
// Copy returns a deep copy of the BaseModel
func (bm *BaseModel) Copy() *BaseModel {
newBm := &BaseModel{
Name: bm.Name,
SliceConfig: bm.SliceConfig,
Triangles: make([]Triangle, len(bm.Triangles)),
Slices: make([]*Slice, len(bm.Slices)),
Bounds: bm.Bounds,
}
copy(newBm.Triangles, bm.Triangles)
copy(newBm.Slices, bm.Slices)
return newBm
}
// AddTriangle adds a triangle to the model
func (bm *BaseModel) AddTriangle(t Triangle) {
bm.Triangles = append(bm.Triangles, t)
}
// GetTriangleCount returns the number of triangles in the model
func (bm *BaseModel) GetTriangleCount() int {
return len(bm.Triangles)
}
// GetBounds calculates the bounding box of the model
func (bm *BaseModel) GetBounds() BoundingBox {
if len(bm.Triangles) == 0 {
return BoundingBox{}
}
// Initialize with first vertex
min := bm.Triangles[0].V1
max := bm.Triangles[0].V1
// Check all vertices
for _, t := range bm.Triangles {
vertices := []Vector3{t.V1, t.V2, t.V3}
for _, v := range vertices {
// Update min
if v.X < min.X {
min.X = v.X
}
if v.Y < min.Y {
min.Y = v.Y
}
if v.Z < min.Z {
min.Z = v.Z
}
// Update max
if v.X > max.X {
max.X = v.X
}
if v.Y > max.Y {
max.Y = v.Y
}
if v.Z > max.Z {
max.Z = v.Z
}
}
}
return BoundingBox{
MinX: min.X,
MinY: min.Y,
MaxX: max.X,
MaxY: max.Y,
MinZ: min.Z,
MaxZ: max.Z,
}
}
// GetVolume calculates the volume of the model (assuming it's a closed mesh)
func (bm *BaseModel) GetVolume() float64 {
var volume float64 = 0.0
for _, t := range bm.Triangles {
// Using the divergence theorem
volume += t.V1.X * (t.V2.Y*t.V3.Z - t.V3.Y*t.V2.Z)
}
return math.Abs(volume) / 6.0
}
// GetSurfaceArea calculates the total surface area of the model
func (bm *BaseModel) GetSurfaceArea() float64 {
var area float64 = 0.0
for _, t := range bm.Triangles {
area += t.Area()
}
return area
}
package model
import "math"
// MeshIssues contains information about mesh problems
type MeshIssues struct {
IsWatertight bool // True if mesh is watertight
InvalidTriangles []Triangle // Triangles with invalid normals
OpenEdges []LineSegment // Edges that are not shared by exactly 2 triangles
DuplicateTriangles []Triangle // Indices of duplicate triangles
DegenerateTriangles []Triangle // Indices of triangles with zero area
EdgeUsageCount map[string]int // How many times each edge is used
BoundaryEdges []LineSegment // Edges used only once (mesh boundary/hole)
NonManifoldEdges []LineSegment // Edges used more than twice (non-manifold)
EdgeUsageCounts map[LineSegment]int // Maps non-manifold edges to their usage count
}
// Validate identifies issues in the mesh such as open edges,
// duplicate triangles, and degenerate triangles.
func (bm *BaseModel) Validate(tolerance float64) *MeshIssues {
issues := &MeshIssues{
IsWatertight: true,
OpenEdges: make([]LineSegment, 0),
DuplicateTriangles: make([]Triangle, 0),
DegenerateTriangles: make([]Triangle, 0),
EdgeUsageCount: make(map[string]int),
BoundaryEdges: make([]LineSegment, 0),
NonManifoldEdges: make([]LineSegment, 0),
InvalidTriangles: make([]Triangle, 0),
EdgeUsageCounts: make(map[LineSegment]int),
}
if len(bm.Triangles) == 0 {
issues.IsWatertight = false
return issues
}
// Track edge usage
edgeCount := make(map[string]int)
edgeOriginal := make(map[string]LineSegment)
// Track triangle uniqueness
triangleSet := make(map[string]bool)
for _, t := range bm.Triangles {
// Check if the normal is valid by comparing with computed normal
// Use dot product to check angle between normals (should be close to 1 for same direction)
computed := t.ComputeNormal()
// Normalize both to be sure they're unit vectors
storedNorm := t.Normal.Normalize()
computedNorm := computed.Normalize()
// Dot product of two unit vectors = cos(angle)
// If angle > 10 degrees, consider it invalid
// cos(10°) ≈ 0.985
dotProduct := storedNorm.Dot(computedNorm)
if dotProduct < 0.985 { // More than 10 degrees difference
issues.InvalidTriangles = append(issues.InvalidTriangles, t)
}
// Check for degenerate triangles
if t.IsDegenerate(tolerance) {
issues.DegenerateTriangles = append(issues.DegenerateTriangles, t)
issues.IsWatertight = false
continue
}
// Check for duplicate triangles
triangleKey := t.Key(tolerance)
if triangleSet[triangleKey] {
issues.DuplicateTriangles = append(issues.DuplicateTriangles, t)
issues.IsWatertight = false
}
triangleSet[triangleKey] = true
// Count each edge of the triangle
edges := []LineSegment{
{t.V1, t.V2},
{t.V2, t.V3},
{t.V3, t.V1},
}
for _, edge := range edges {
key := edge.Key(tolerance)
edgeCount[key]++
if _, exists := edgeOriginal[key]; !exists {
edgeOriginal[key] = LineSegment{Start: edge.Start, End: edge.End}
}
}
}
// Check edge usage - each edge should be used exactly twice in a watertight mesh
for key, count := range edgeCount {
issues.EdgeUsageCount[key] = count
if count != 2 {
edge := edgeOriginal[key]
issues.OpenEdges = append(issues.OpenEdges, edge)
issues.IsWatertight = false
// Categorize the type of problem
if count == 1 {
issues.BoundaryEdges = append(issues.BoundaryEdges, edge)
} else if count > 2 {
issues.NonManifoldEdges = append(issues.NonManifoldEdges, edge)
issues.EdgeUsageCounts[edge] = count
}
}
}
return issues
}
// CleanBounds recalculates the bounds of the model and updates the struct
func (bm *BaseModel) CleanBounds() *BaseModel {
newBm := bm.Copy()
newBm.Bounds = newBm.GetBounds()
return newBm
}
// CleanMesh repairs common mesh issues:
// - Removes degenerate triangles (zero area)
// - Removes duplicate triangles
// - Fixes invalid normals by recomputing them
func (bm *BaseModel) CleanMesh(tolerance float64) *BaseModel {
cleaned := bm.Copy()
cleaned.Triangles = make([]Triangle, 0) // Reset triangles but keep other info
// Create sets for filtering
issues := bm.Validate(tolerance)
degenerateSet := make(map[string]bool)
for _, t := range issues.DegenerateTriangles {
degenerateSet[t.Key(tolerance)] = true
}
duplicateSet := make(map[string]bool)
for _, t := range issues.DuplicateTriangles {
duplicateSet[t.Key(tolerance)] = true
}
invalidSet := make(map[string]bool)
for _, t := range issues.InvalidTriangles {
invalidSet[t.Key(tolerance)] = true
}
addedTriangles := make(map[string]bool)
for _, t := range bm.Triangles {
key := t.Key(tolerance)
if degenerateSet[key] {
continue
} else if addedTriangles[key] {
continue
}
cleanedTriangle := t
if invalidSet[key] {
cleanedTriangle.Normal = t.ComputeNormal()
}
cleaned.Triangles = append(cleaned.Triangles, cleanedTriangle)
addedTriangles[key] = true
}
return cleaned
}
// CleanPosition recenters the model to the origin and moves it so the lowest point is at Z=0
func (bm *BaseModel) CleanPosition() *BaseModel {
// Find center and bottom of the model
bounds := bm.GetBounds()
centerX := (bounds.MinX + bounds.MaxX) / 2
centerY := (bounds.MinY + bounds.MaxY) / 2
lowestZ := bounds.MinZ
// Create a new BaseModel with centered triangles
centered := bm.Copy()
centered.Triangles = make([]Triangle, len(bm.Triangles))
for i, t := range bm.Triangles {
centered.Triangles[i] = Triangle{
Normal: t.Normal,
V1: Vector3{
X: t.V1.X - centerX,
Y: t.V1.Y - centerY,
Z: t.V1.Z - lowestZ,
},
V2: Vector3{
X: t.V2.X - centerX,
Y: t.V2.Y - centerY,
Z: t.V2.Z - lowestZ,
},
V3: Vector3{
X: t.V3.X - centerX,
Y: t.V3.Y - centerY,
Z: t.V3.Z - lowestZ,
},
}
}
return centered
}
// CleanBottom flattens all vertices below the given zThreshold to create a flat base plane
// All vertices with Z <= zThreshold will have their Z coordinate set to the minimum Z value found
func (bm *BaseModel) CleanBottom(zThreshold float64) *BaseModel {
if len(bm.Triangles) == 0 {
return bm
}
// Find the minimum Z value among all vertices below the threshold
minZ := float64(math.Inf(1))
for _, tri := range bm.Triangles {
if tri.V1.Z <= zThreshold {
minZ = math.Min(minZ, tri.V1.Z)
}
if tri.V2.Z <= zThreshold {
minZ = math.Min(minZ, tri.V2.Z)
}
if tri.V3.Z <= zThreshold {
minZ = math.Min(minZ, tri.V3.Z)
}
}
// If no vertices found below threshold, return unchanged
if math.IsInf(minZ, 1) {
return bm
}
// Flatten vertices below threshold
flattened := bm.Copy()
flattened.Triangles = make([]Triangle, len(bm.Triangles))
for i, tri := range bm.Triangles {
newTri := tri
if tri.V1.Z <= zThreshold {
newTri.V1.Z = minZ
}
if tri.V2.Z <= zThreshold {
newTri.V2.Z = minZ
}
if tri.V3.Z <= zThreshold {
newTri.V3.Z = minZ
}
// Recalculate normal after modifying vertices
newTri.Normal = newTri.ComputeNormal()
flattened.Triangles[i] = newTri
}
return flattened
}
// CleanSize returns a new BaseModel with all vertices normalized to fit in a unit cube centered at origin
func (bm *BaseModel) CleanSize() *BaseModel {
if len(bm.Triangles) == 0 {
return bm
}
// Calculate center and largest dimension
bounds := bm.GetBounds()
center := Vector3{
X: (bounds.MinX + bounds.MaxX) / 2,
Y: (bounds.MinY + bounds.MaxY) / 2,
Z: (bounds.MinZ + bounds.MaxZ) / 2,
}
sizeX := bounds.MaxX - bounds.MinX
sizeY := bounds.MaxY - bounds.MinY
sizeZ := bounds.MaxZ - bounds.MinZ
maxSize := sizeX
if sizeY > maxSize {
maxSize = sizeY
}
if sizeZ > maxSize {
maxSize = sizeZ
}
// Avoid division by zero
if maxSize == 0 {
return bm
}
// Scale to fit in 100 units (makes it easier to work with in viewer)
scale := 100.0 / maxSize
// Transform all triangles
normalized := bm.Copy()
normalized.Triangles = make([]Triangle, len(bm.Triangles))
for i, t := range bm.Triangles {
normalized.Triangles[i] = Triangle{
Normal: t.Normal, // Normal doesn't need scaling
V1: Vector3{
X: (t.V1.X - center.X) * scale,
Y: (t.V1.Y - center.Y) * scale,
Z: (t.V1.Z - center.Z) * scale,
},
V2: Vector3{
X: (t.V2.X - center.X) * scale,
Y: (t.V2.Y - center.Y) * scale,
Z: (t.V2.Z - center.Z) * scale,
},
V3: Vector3{
X: (t.V3.X - center.X) * scale,
Y: (t.V3.Y - center.Y) * scale,
Z: (t.V3.Z - center.Z) * scale,
},
}
}
return normalized
}
package model
import (
"log"
"math"
)
// Collapse uses voxel-based vertex clustering for fast mesh decimation
func (bm *BaseModel) Collapse(targetTriangleCount int) *BaseModel {
if targetTriangleCount >= len(bm.Triangles) {
lowRes := NewBaseModel(bm.Name + "_region")
lowRes.Triangles = make([]Triangle, len(bm.Triangles))
copy(lowRes.Triangles, bm.Triangles)
return lowRes.CleanSize()
}
// Step 1: Calculate bounding box
if len(bm.Triangles) == 0 {
return bm
}
bounds := bm.GetBounds()
min := Vector3{X: bounds.MinX, Y: bounds.MinY, Z: bounds.MinZ}
max := Vector3{X: bounds.MaxX, Y: bounds.MaxY, Z: bounds.MaxZ}
// Step 2: Calculate grid resolution
// We want roughly the right number of voxels to achieve the target reduction
// More triangles needed = finer grid (more voxels)
// Estimate: each voxel contains roughly (total triangles / target triangles) vertices
// So we need roughly targetTriangles^(1/3) voxels per dimension
voxelsPerDim := int(math.Pow(float64(targetTriangleCount), 1.0/3.0) * 1.5)
if voxelsPerDim < 5 {
voxelsPerDim = 5
} else if voxelsPerDim > 300 {
voxelsPerDim = 300
}
// Step 3: Calculate voxel size
size := Vector3{
X: (max.X - min.X) / float64(voxelsPerDim),
Y: (max.Y - min.Y) / float64(voxelsPerDim),
Z: (max.Z - min.Z) / float64(voxelsPerDim),
}
// Add small epsilon to avoid edge cases
size.X += 0.0001
size.Y += 0.0001
size.Z += 0.0001
// Step 4: Build voxel grid - map from voxel coordinate to merged vertex
type VoxelKey struct{ x, y, z int }
voxelVertices := make(map[VoxelKey]Vector3)
voxelCounts := make(map[VoxelKey]int)
getVoxelKey := func(v Vector3) VoxelKey {
return VoxelKey{
x: int((v.X - min.X) / size.X),
y: int((v.Y - min.Y) / size.Y),
z: int((v.Z - min.Z) / size.Z),
}
}
// Accumulate all vertices in each voxel
for _, tri := range bm.Triangles {
for _, v := range []Vector3{tri.V1, tri.V2, tri.V3} {
key := getVoxelKey(v)
current := voxelVertices[key]
current.X += v.X
current.Y += v.Y
current.Z += v.Z
voxelVertices[key] = current
voxelCounts[key]++
}
}
// Average vertices in each voxel
for key, sum := range voxelVertices {
count := float64(voxelCounts[key])
voxelVertices[key] = Vector3{
X: sum.X / count,
Y: sum.Y / count,
Z: sum.Z / count,
}
}
log.Printf("Created %d voxels with merged vertices", len(voxelVertices))
// Step 5: Rebuild triangles with merged vertices
var result []Triangle
for _, tri := range bm.Triangles {
// Map each vertex to its voxel's merged vertex
v1 := voxelVertices[getVoxelKey(tri.V1)]
v2 := voxelVertices[getVoxelKey(tri.V2)]
v3 := voxelVertices[getVoxelKey(tri.V3)]
// Skip degenerate triangles (where all vertices collapsed to same point or line)
if v1 == v2 || v2 == v3 || v1 == v3 {
continue
}
// Calculate new normal
edge1 := Vector3{X: v2.X - v1.X, Y: v2.Y - v1.Y, Z: v2.Z - v1.Z}
edge2 := Vector3{X: v3.X - v1.X, Y: v3.Y - v1.Y, Z: v3.Z - v1.Z}
normal := Vector3{
X: edge1.Y*edge2.Z - edge1.Z*edge2.Y,
Y: edge1.Z*edge2.X - edge1.X*edge2.Z,
Z: edge1.X*edge2.Y - edge1.Y*edge2.X,
}
// Skip if normal is zero (degenerate triangle)
length := math.Sqrt(float64(normal.X*normal.X + normal.Y*normal.Y + normal.Z*normal.Z))
if length < 0.0001 {
continue
}
result = append(result, Triangle{
Normal: normal.Normalize(),
V1: v1,
V2: v2,
V3: v3,
})
}
log.Printf("RegionDecimate: Result has %d triangles (removed %d degenerate)", len(result), len(bm.Triangles)-len(result))
// Build result BaseModel
lowRes := NewBaseModel(bm.Name + "_voxel")
lowRes.Triangles = result
return lowRes.CleanSize()
}
package model
import (
"fmt"
)
// Slice slices the model into horizontal layers
func (bm *BaseModel) Slice(config *SliceConfig) error {
if len(bm.Triangles) == 0 {
return fmt.Errorf("cannot slice empty model")
}
// Get model bounds to determine layer heights
bounds := bm.GetBounds()
layerHeights := config.CalculateLayerHeights(bounds.MinZ, bounds.MaxZ)
// Create slices for each layer
bm.Slices = make([]*Slice, len(layerHeights))
for i, z := range layerHeights {
bm.Slices[i] = &Slice{
Z: z,
Segments: make([]LineSegment, 0),
Polygons: make([]Polygon, 0),
}
}
// For each triangle, find which layers it intersects and compute segments
for _, triangle := range bm.Triangles {
// Get triangle Z bounds
minZ := triangle.V1.Z
maxZ := triangle.V1.Z
if triangle.V2.Z < minZ {
minZ = triangle.V2.Z
}
if triangle.V3.Z < minZ {
minZ = triangle.V3.Z
}
if triangle.V2.Z > maxZ {
maxZ = triangle.V2.Z
}
if triangle.V3.Z > maxZ {
maxZ = triangle.V3.Z
}
// Find which layers this triangle intersects
for i, z := range layerHeights {
if z >= minZ && z <= maxZ {
// Compute intersection segment
if segment, ok := triangle.IntersectZPlane(z); ok {
bm.Slices[i].Segments = append(bm.Slices[i].Segments, segment)
}
}
}
}
// Build contours from segments for each slice
for _, slice := range bm.Slices {
slice.BuildPolygons(config.Tolerance)
}
return nil
}
// Helper functions
// ClassifyTopBottomLayers determines which layers should be solid (top/bottom)
func (bm *BaseModel) ClassifyTopBottomLayers(config *SliceConfig) {
if len(bm.Slices) == 0 {
return
}
// Mark bottom layers
for i := 0; i < config.BottomLayers && i < len(bm.Slices); i++ {
bm.Slices[i].IsBottomLayer = true
bm.Slices[i].LayerIndex = i
}
// Mark top layers
startTop := len(bm.Slices) - config.TopLayers
if startTop < 0 {
startTop = 0
}
for i := startTop; i < len(bm.Slices); i++ {
bm.Slices[i].IsTopLayer = true
bm.Slices[i].LayerIndex = i
}
// Set layer index for all layers
for i := range bm.Slices {
bm.Slices[i].LayerIndex = i
}
}
package model
import (
"fmt"
"math"
)
// LineSegment represents a line segment with two endpoints
type LineSegment struct {
Start Vector3
End Vector3
}
// Key creates a consistent key for an edge (order-independent)
func (e *LineSegment) Key(tolerance float64) string {
// Round vertices to tolerance to handle floating point precision
round := func(v Vector3) Vector3 {
scale := float64(1.0 / tolerance)
return Vector3{
math.Round(v.X*scale) / scale,
math.Round(v.Y*scale) / scale,
math.Round(v.Z*scale) / scale,
}
}
r1 := round(e.Start)
r2 := round(e.End)
// Ensure consistent ordering
if r1.X < r2.X || (r1.X == r2.X && r1.Y < r2.Y) || (r1.X == r2.X && r1.Y == r2.Y && r1.Z < r2.Z) {
return fmt.Sprintf("%.6f,%.6f,%.6f-%.6f,%.6f,%.6f", r1.X, r1.Y, r1.Z, r2.X, r2.Y, r2.Z)
}
return fmt.Sprintf("%.6f,%.6f,%.6f-%.6f,%.6f,%.6f", r2.X, r2.Y, r2.Z, r1.X, r1.Y, r1.Z)
}
// IntersectLines finds the intersection of two infinite lines (not segments)
// Returns the parameter t along the first line and whether intersection exists
func (l LineSegment) IntersectLines(line LineSegment) (float64, bool) {
p1, p2 := l.Start, l.End
p3, p4 := line.Start, line.End
denom := (p1.X-p2.X)*(p3.Y-p4.Y) - (p1.Y-p2.Y)*(p3.X-p4.X)
if math.Abs(denom) < 1e-10 {
return 0, false // Lines are parallel
}
t := ((p1.X-p3.X)*(p3.Y-p4.Y) - (p1.Y-p3.Y)*(p3.X-p4.X)) / denom
return t, true
}
// IntersectSegments checks if two line segments intersect
// Returns the parameters (t1, t2) and whether they intersect
func (l LineSegment) IntersectSegments(seg LineSegment) (Vector3, bool) {
p1, p2 := l.Start, l.End
p3, p4 := seg.Start, seg.End
denom := (p1.X-p2.X)*(p3.Y-p4.Y) - (p1.Y-p2.Y)*(p3.X-p4.X)
if math.Abs(denom) < 1e-10 {
return Vector3{}, false // Segments are parallel
}
t1 := ((p1.X-p3.X)*(p3.Y-p4.Y) - (p1.Y-p3.Y)*(p3.X-p4.X)) / denom
t2 := -((p1.X-p2.X)*(p1.Y-p3.Y) - (p1.Y-p2.Y)*(p1.X-p3.X)) / denom
// Check if intersection is within both segments
if t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 <= 1 {
return Vector3{
X: p1.X + t1*(p2.X-p1.X),
Y: p1.Y + t1*(p2.Y-p1.Y),
Z: p1.Z + t1*(p2.Z-p1.Z),
}, true
}
return Vector3{}, false
}
// IntersectZPlane finds where an edge intersects a horizontal plane
func (l LineSegment) IntersectZPlane(z float64) (Vector3, bool) {
// Check if edge crosses the plane (vertices on different sides)
if (l.Start.Z < z && l.End.Z < z) || (l.Start.Z > z && l.End.Z > z) {
return Vector3{}, false // Edge doesn't cross plane
}
// Handle edge exactly on plane
if l.Start.Z == z && l.End.Z == z {
return Vector3{}, false // Edge lies in plane (not a crossing)
}
// One vertex exactly on plane
if l.Start.Z == z {
return l.Start, true
}
if l.End.Z == z {
return l.End, true
}
// Compute intersection point using linear interpolation
t := (z - l.Start.Z) / (l.End.Z - l.Start.Z)
return Vector3{
X: l.Start.X + t*(l.End.X-l.Start.X),
Y: l.Start.Y + t*(l.End.Y-l.Start.Y),
Z: z,
}, true
}
package model
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"strconv"
"strings"
)
// 3MF XML Structures
type modelXML struct {
XMLName xml.Name `xml:"model"`
Unit string `xml:"unit,attr"`
Metadata []metadataXML `xml:"metadata"`
Resources resourcesXML `xml:"resources"`
Build buildXML `xml:"build"`
}
type metadataXML struct {
Name string `xml:"name,attr"`
Value string `xml:",chardata"`
}
type resourcesXML struct {
Objects []objectXML `xml:"object"`
}
type objectXML struct {
ID int `xml:"id,attr"`
Type string `xml:"type,attr"`
Mesh meshXML `xml:"mesh"`
}
type meshXML struct {
Vertices []vertexXML `xml:"vertices>vertex"`
Indices []polygonXML `xml:"triangles>triangle"`
}
type vertexXML struct {
X float64 `xml:"x,attr"`
Y float64 `xml:"y,attr"`
Z float64 `xml:"z,attr"`
}
type polygonXML struct {
V1 int `xml:"v1,attr"`
V2 int `xml:"v2,attr"`
V3 int `xml:"v3,attr"`
}
type buildXML struct {
Items []itemXML `xml:"item"`
}
type itemXML struct {
ObjectID int `xml:"objectid,attr"`
Transform string `xml:"transform,attr"`
}
// Load3MF loads a 3MF file from an io.Reader and returns a BaseModel
func Load3MF(r io.Reader, modelName string) (*BaseModel, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
var modelData *modelXML
// Look for the model file
for _, f := range zr.File {
if f.Name == "3D/3dmodel.model" {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
decoder := xml.NewDecoder(rc)
if err := decoder.Decode(&modelData); err != nil {
return nil, err
}
break
}
}
if modelData == nil {
return nil, fmt.Errorf("could not find 3D/3dmodel.model in 3MF package")
}
bm := NewBaseModel(modelName)
bm.SliceConfig = NewSliceConfig()
// Parse Metadata into SliceConfig
parse3MFMetadata(modelData.Metadata, bm.SliceConfig)
// Map unit to scale
unitScale := 1.0
switch strings.ToLower(modelData.Unit) {
case "inch":
unitScale = 25.4
case "millimeter":
unitScale = 1.0
case "centimeter":
unitScale = 10.0
case "meter":
unitScale = 1000.0
}
// Create objects map for quick lookup
objects := make(map[int]objectXML)
for _, obj := range modelData.Resources.Objects {
objects[obj.ID] = obj
}
// Build the model from items
for _, item := range modelData.Build.Items {
obj, ok := objects[item.ObjectID]
if !ok {
continue
}
// TODO: Apply transform matrix from item.Transform
// For now, assume identity or simple translation if present
for _, tri := range obj.Mesh.Indices {
v1 := obj.Mesh.Vertices[tri.V1]
v2 := obj.Mesh.Vertices[tri.V2]
v3 := obj.Mesh.Vertices[tri.V3]
triangle := Triangle{
V1: Vector3{X: v1.X * unitScale, Y: v1.Y * unitScale, Z: v1.Z * unitScale},
V2: Vector3{X: v2.X * unitScale, Y: v2.Y * unitScale, Z: v2.Z * unitScale},
V3: Vector3{X: v3.X * unitScale, Y: v3.Y * unitScale, Z: v3.Z * unitScale},
}
triangle.Normal = triangle.ComputeNormal()
bm.AddTriangle(triangle)
}
}
bm.Bounds = bm.GetBounds()
return bm, nil
}
func parse3MFMetadata(metadata []metadataXML, config *SliceConfig) {
for _, meta := range metadata {
val := meta.Value
switch strings.ToLower(meta.Name) {
case "title":
// skip
case "designer":
// skip
case "layerheight", "slic3rpe:layer_height", "cura:layer_height":
if f, err := strconv.ParseFloat(val, 64); err == nil {
config.LayerHeight = f
}
case "infilldensity", "slic3rpe:fill_density", "cura:infill_sparse_density":
// 3mf often uses percentage (e.g. 20) while we use 0.0-1.0
if f, err := strconv.ParseFloat(strings.TrimSuffix(val, "%"), 64); err == nil {
if f > 1.0 {
config.InfillDensity = f / 100.0
} else {
config.InfillDensity = f
}
}
case "nozzletemp", "slic3rpe:temperature", "cura:material_print_temperature":
if f, err := strconv.ParseFloat(val, 64); err == nil {
config.NozzleTemp = f
}
case "bedtemp", "slic3rpe:bed_temperature", "cura:material_bed_temperature":
if f, err := strconv.ParseFloat(val, 64); err == nil {
config.BedTemp = f
}
}
}
}
package model
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"strings"
"github.com/siherrmann/slicer/helper"
)
// STL represents a 3D model in STL format
type STL struct {
BaseModel
IsBinary bool `json:"is_binary"`
Header [80]byte `json:"header"`
}
// NewSTL creates a new empty STL model
func NewSTL(name string) *STL {
return &STL{
BaseModel: *NewBaseModel(name),
IsBinary: false,
}
}
// LoadSTL loads an STL file from a reader (supports both ASCII and binary formats)
func LoadSTL(r io.Reader, name string) (*STL, error) {
// Read all data into buffer to detect format
data, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("failed to read STL data: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("empty STL file")
}
// Detect format: ASCII files start with "solid"
// But be careful - some binary files might have "solid" in the header
var stl *STL
if detectASCIIFormat(data) {
stl, err = loadASCIISTL(data, name)
if err != nil {
return nil, err
}
return stl, nil
} else {
stl, err = loadBinarySTL(data, name)
if err != nil {
return nil, err
}
}
stl.Bounds = stl.GetBounds()
return stl, nil
}
// detectASCIIFormat determines if the STL is ASCII or binary
func detectASCIIFormat(data []byte) bool {
// ASCII STL files start with "solid"
if !bytes.HasPrefix(data, []byte("solid")) {
return false
}
// Binary files might have "solid" in header, but they have a specific structure
// Binary format: 80-byte header + 4-byte count + triangles (50 bytes each)
if len(data) >= 84 {
// Try to read triangle count from binary format
triangleCount := binary.LittleEndian.Uint32(data[80:84])
expectedSize := 84 + int(triangleCount)*50
// If the file size matches binary format exactly, it's binary
if len(data) == expectedSize {
return false
}
}
return true
}
// loadBinarySTL loads a binary STL file
func loadBinarySTL(data []byte, name string) (*STL, error) {
if len(data) < 84 {
return nil, fmt.Errorf("binary STL file too small (minimum 84 bytes)")
}
stl := NewSTL(name)
stl.IsBinary = true
// Read 80-byte header
copy(stl.Header[:], data[0:80])
// Read triangle count
triangleCount := binary.LittleEndian.Uint32(data[80:84])
// Validate file size
expectedSize := 84 + int(triangleCount)*50
if len(data) != expectedSize {
return nil, fmt.Errorf("invalid binary STL: expected %d bytes, got %d bytes", expectedSize, len(data))
}
// Read triangles
offset := 84
for i := 0; i < int(triangleCount); i++ {
if offset+50 > len(data) {
return nil, fmt.Errorf("unexpected end of file at triangle %d", i)
}
triangle := Triangle{}
// Read normal vector (3 float32)
triangle.Normal.X = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset : offset+4])))
triangle.Normal.Y = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+4 : offset+8])))
triangle.Normal.Z = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+8 : offset+12])))
offset += 12
// Read vertex 1 (3 float32)
triangle.V1.X = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset : offset+4])))
triangle.V1.Y = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+4 : offset+8])))
triangle.V1.Z = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+8 : offset+12])))
offset += 12
// Read vertex 2 (3 float32)
triangle.V2.X = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset : offset+4])))
triangle.V2.Y = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+4 : offset+8])))
triangle.V2.Z = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+8 : offset+12])))
offset += 12
// Read vertex 3 (3 float32)
triangle.V3.X = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset : offset+4])))
triangle.V3.Y = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+4 : offset+8])))
triangle.V3.Z = float64(math.Float32frombits(binary.LittleEndian.Uint32(data[offset+8 : offset+12])))
offset += 12
// Read attribute byte count (uint16)
triangle.Attr = binary.LittleEndian.Uint16(data[offset : offset+2])
offset += 2
stl.AddTriangle(triangle)
}
return stl, nil
}
// loadASCIISTL loads an ASCII STL file
func loadASCIISTL(data []byte, name string) (*STL, error) {
stl := NewSTL(name)
stl.IsBinary = false
scanner := bufio.NewScanner(bytes.NewReader(data))
var currentTriangle *Triangle
var vertexCount int
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
fields := strings.Fields(line)
if line == "" || len(fields) == 0 {
continue
}
keyword := fields[0]
switch keyword {
case "solid":
// Optional name after "solid"
if len(fields) > 1 && stl.Name == name {
stl.Name = strings.Join(fields[1:], " ")
}
case "facet":
if len(fields) < 5 || fields[1] != "normal" {
return nil, fmt.Errorf("line %d: invalid facet normal declaration", lineNum)
}
currentTriangle = &Triangle{}
var err error
currentTriangle.Normal.X, err = helper.ParseFloat64(fields[2])
if err != nil {
return nil, fmt.Errorf("line %d: invalid normal X: %w", lineNum, err)
}
currentTriangle.Normal.Y, err = helper.ParseFloat64(fields[3])
if err != nil {
return nil, fmt.Errorf("line %d: invalid normal Y: %w", lineNum, err)
}
currentTriangle.Normal.Z, err = helper.ParseFloat64(fields[4])
if err != nil {
return nil, fmt.Errorf("line %d: invalid normal Z: %w", lineNum, err)
}
vertexCount = 0
case "vertex":
if currentTriangle == nil {
return nil, fmt.Errorf("line %d: vertex outside facet", lineNum)
}
if len(fields) < 4 {
return nil, fmt.Errorf("line %d: invalid vertex declaration", lineNum)
}
var v Vector3
var err error
v.X, err = helper.ParseFloat64(fields[1])
if err != nil {
return nil, fmt.Errorf("line %d: invalid vertex X: %w", lineNum, err)
}
v.Y, err = helper.ParseFloat64(fields[2])
if err != nil {
return nil, fmt.Errorf("line %d: invalid vertex Y: %w", lineNum, err)
}
v.Z, err = helper.ParseFloat64(fields[3])
if err != nil {
return nil, fmt.Errorf("line %d: invalid vertex Z: %w", lineNum, err)
}
switch vertexCount {
case 0:
currentTriangle.V1 = v
case 1:
currentTriangle.V2 = v
case 2:
currentTriangle.V3 = v
default:
return nil, fmt.Errorf("line %d: too many vertices in facet", lineNum)
}
vertexCount++
case "endfacet":
if currentTriangle == nil {
return nil, fmt.Errorf("line %d: endfacet without facet", lineNum)
}
if vertexCount != 3 {
return nil, fmt.Errorf("line %d: facet has %d vertices, expected 3", lineNum, vertexCount)
}
stl.AddTriangle(*currentTriangle)
currentTriangle = nil
case "endsolid":
// End of file - just continue parsing in case there's more content
continue
case "outer", "loop", "endloop":
// These keywords are expected but don't require action
continue
default:
// Ignore unknown keywords (for compatibility)
continue
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading STL: %w", err)
}
if len(stl.Triangles) == 0 {
return nil, fmt.Errorf("no triangles found in STL file")
}
return stl, nil
}
func (stl *STL) GetBounds() BoundingBox {
return stl.BaseModel.GetBounds()
}
package model
import "math"
// PathSegment represents a single segment in a path
type PathSegment struct {
Start Vector3 `json:"start"`
End Vector3 `json:"end"`
IsTravel bool `json:"is_travel,omitempty"`
Speed float64 `json:"speed,omitempty"` // Intended print speed (mm/s), 0 = use default
FlowRate float64 `json:"flow_rate,omitempty"` // Flow rate multiplier, 0 = use config default
Category PathCategory `json:"category,omitempty"` // Category for per-type speed/visibility
}
type PathType int
const (
PathTravel PathType = iota
PathExtrusion
)
// ContinuousPath represents a single continuous extrusion path
type ContinuousPath struct {
Segments []PathSegment `json:"segments"`
PathType PathType `json:"path_type"`
LayerIndex int `json:"-"` // Layer index for G-code export
}
// PrintResult holds statistics about a print path
type PrintResult struct {
PrintTime float64 // Estimated print time in seconds
ExtrusionPathLength float64 // Total length of extrusion moves in mm
TravelPathLength float64 // Total length of travel moves in mm
MovementPath float64 // Total length of all moves in mm
}
// GetPrintResult calculates the length and time statistics for the path
func (p *ContinuousPath) GetPrintResult(config *SliceConfig) PrintResult {
var result PrintResult
for _, seg := range p.Segments {
length := seg.Start.Distance(seg.End)
if seg.IsTravel {
result.TravelPathLength += length
if config.TravelSpeed > 0 {
result.PrintTime += length / config.TravelSpeed
}
} else {
result.ExtrusionPathLength += length
speed := p.getSegmentSpeed(seg, config)
if speed > 0 {
result.PrintTime += length / speed
}
}
}
result.MovementPath = result.ExtrusionPathLength + result.TravelPathLength
return result
}
func (p *ContinuousPath) getSegmentSpeed(seg PathSegment, config *SliceConfig) float64 {
var speed float64
if seg.Speed > 0 {
speed = seg.Speed
} else {
switch seg.Category {
case CategoryOuterWall:
speed = config.OuterShellSpeed
case CategoryInnerWall:
speed = config.WallSpeed
case CategoryInfill:
speed = config.InfillSpeed
case CategorySolidInfill:
speed = config.InfillSpeed * 0.8
case CategorySupport:
speed = config.SupportSpeed
case CategorySkirt, CategoryBrim:
speed = config.WallSpeed
default:
speed = config.InfillSpeed
}
}
if p.LayerIndex == 0 && config.FirstLayerSpeed > 0 {
speed = math.Min(speed, config.FirstLayerSpeed)
}
return math.Max(speed, config.MinSpeed)
}
package model
import (
"math"
)
// Polygon represents a 2D polygon
type Polygon struct {
Points []Vector3
IsClosed bool // Whether the contour forms a closed loop
IsHole bool // Whether this contour is a hole (inner boundary)
}
// BoundingBox represents a 2D bounding box (XY plane)
type BoundingBox struct {
MinX float64 `json:"min_x"`
MinY float64 `json:"min_y"`
MaxX float64 `json:"max_x"`
MaxY float64 `json:"max_y"`
MinZ float64 `json:"min_z"`
MaxZ float64 `json:"max_z"`
}
// GetArea calculates the signed area of a polygon
func (p *Polygon) GetArea() float64 {
if len(p.Points) < 3 {
return 0
}
var area float64
n := len(p.Points)
for i := 0; i < n; i++ {
j := (i + 1) % n
area += p.Points[i].X * p.Points[j].Y
area -= p.Points[j].X * p.Points[i].Y
}
return area / 2
}
// GetBounds calculates the 2D bounding box of a contour
func (p *Polygon) GetBounds() BoundingBox {
if len(p.Points) == 0 {
return BoundingBox{}
}
bounds := BoundingBox{
MinX: p.Points[0].X,
MinY: p.Points[0].Y,
MaxX: p.Points[0].X,
MaxY: p.Points[0].Y,
}
for _, p := range p.Points[1:] {
if p.X < bounds.MinX {
bounds.MinX = p.X
}
if p.X > bounds.MaxX {
bounds.MaxX = p.X
}
if p.Y < bounds.MinY {
bounds.MinY = p.Y
}
if p.Y > bounds.MaxY {
bounds.MaxY = p.Y
}
}
return bounds
}
// GetLines returns the edges of the polygon as line segments
func (p *Polygon) GetLines() []LineSegment {
lines := make([]LineSegment, len(p.Points))
for i, vec := range p.Points {
if i%2 == 0 && i < len(p.Points)-1 {
lines[i] = LineSegment{Start: vec, End: p.Points[(i+1)%len(p.Points)]}
} else if i%2 == 0 {
lines[i] = LineSegment{Start: vec, End: p.Points[0]}
} else {
lines[i] = LineSegment{Start: p.Points[(i+1)%len(p.Points)], End: vec}
}
}
return lines
}
// IsClockwise returns true if polygon is oriented clockwise
func (p *Polygon) IsClockwise() bool {
return p.GetArea() < 0
}
// Reverse reverses the order of points
func (p *Polygon) Reverse() {
n := len(p.Points)
for i := 0; i < n/2; i++ {
p.Points[i], p.Points[n-1-i] = p.Points[n-1-i], p.Points[i]
}
}
func (p *Polygon) Rotate(angle float64) *Polygon {
newPoints := make([]Vector3, len(p.Points))
for i, pt := range p.Points {
newPoints[i] = pt.Rotate(angle)
}
return &Polygon{
Points: newPoints,
IsClosed: p.IsClosed,
IsHole: p.IsHole,
}
}
// SetZ recursively updates the Z coordinate of all points in the polygon
func (p *Polygon) SetZ(z float64) {
for i := range p.Points {
p.Points[i].Z = z
}
}
// ToContinuousPath converts a Polygon into a ContinuousPath with the given speed and category.
func (p *Polygon) ToContinuousPath(speed float64, category PathCategory, layerIndex int) ContinuousPath {
if len(p.Points) < 2 {
return ContinuousPath{}
}
var segments []PathSegment
for j := 0; j < len(p.Points); j++ {
nextIdx := (j + 1) % len(p.Points)
// If the polygon is not closed, don't connect the last point to the first
if !p.IsClosed && j == len(p.Points)-1 {
continue
}
segments = append(segments, PathSegment{
Start: p.Points[j],
End: p.Points[nextIdx],
IsTravel: false,
Speed: speed,
Category: category,
})
}
return ContinuousPath{
Segments: segments,
PathType: PathExtrusion,
LayerIndex: layerIndex,
}
}
// OffsetPolygon creates a new polygon offset by the given distance
// Positive distance offsets outward, negative offsets inward
func (p *Polygon) OffsetPolygon(distance float64) *Polygon {
if len(p.Points) < 3 {
return nil
}
// Step 0: Pre-clean the polygon to remove microscopic or collinear segments.
// This prevents numerical instability leading to millions of mm of wall paths.
cleanedPoints := ramerDouglasPeuckerPolygon(p.Points, 0.001)
if len(cleanedPoints) < 3 {
return nil
}
// Step 1: Offset each edge by the perpendicular distance
n := len(cleanedPoints)
offsetEdges := make([]LineSegment, n)
for i := range n {
j := (i + 1) % n
p1 := cleanedPoints[i]
p2 := cleanedPoints[j]
// Edge vector
edge := p2.Sub(p1)
edgeLen := edge.Length()
var offset Vector3
if edgeLen < 1e-10 {
// For degenerate edges, use zero offset to preserve Z coordinate
offset = Vector3{X: 0, Y: 0, Z: 0}
} else {
// Perpendicular vector (rotate 90° counterclockwise)
perp := edge.Perpendicular()
perp = perp.Normalize()
// Offset the edge
offset = perp.Scale(-distance)
}
offsetEdges[i] = LineSegment{
Start: p1.Add(offset),
End: p2.Add(offset),
}
}
// Step 2: Find intersections at corners
newPoints := make([]Vector3, 0, n)
for i := range n {
prevEdge := offsetEdges[(i-1+n)%n]
currEdge := offsetEdges[i]
// Find intersection of the two offset edges
t, valid := prevEdge.IntersectLines(currEdge)
if valid {
// Calculate intersection point
intersection := Vector3{
X: prevEdge.Start.X + t*(prevEdge.End.X-prevEdge.Start.X),
Y: prevEdge.Start.Y + t*(prevEdge.End.Y-prevEdge.Start.Y),
Z: prevEdge.Start.Z + t*(prevEdge.End.Z-prevEdge.Start.Z),
}
newPoints = append(newPoints, intersection)
} else {
// Parallel edges - use the end point of previous edge
newPoints = append(newPoints, prevEdge.End)
}
}
if len(newPoints) < 3 {
return nil
}
result := &Polygon{Points: newPoints}
// Step 3: Remove self-intersections (simplified approach)
result = removeSelfIntersections(result)
// Step 4: Simplify perfectly collinear or overly dense vertices to fix 200MB JSON bloat
if result != nil && len(result.Points) > 3 {
// Use a conservative simplification tolerance (e.g. 0.05mm) to keep curves round
// but annihilate microscopic 0.001mm segments inherited from dense STL files
simplified := ramerDouglasPeuckerPolygon(result.Points, 0.05)
if len(simplified) > 3 {
result.Points = simplified
}
}
return result
}
// Private helper to simplify closed polygon loops
func ramerDouglasPeuckerPolygon(points []Vector3, epsilon float64) []Vector3 {
// For closed loops, we don't want to accidentally delete the entire shape.
// We'll just simplify it as an open path, and then if the start != end, that's fine.
// We just ensure we don't reduce a circle to a triangle.
if len(points) <= 3 {
return points
}
// We need to implement a simple collinear decimation instead to be safe,
// or RDP if we want true geometric simplification.
// Since RDP is already in `core`, let's just do collinear here to be fast and safe.
var decimated []Vector3
decimated = append(decimated, points[0])
for k := 1; k < len(points)-1; k++ {
pPrev := decimated[len(decimated)-1]
pCurr := points[k]
pNext := points[k+1]
dx1 := pCurr.X - pPrev.X
dy1 := pCurr.Y - pPrev.Y
dx2 := pNext.X - pCurr.X
dy2 := pNext.Y - pCurr.Y
cross := dx1*dy2 - dy1*dx2
// If points are perfectly straight, skip adding pCurr (0.01mm tolerance)
if math.Abs(cross) > 0.001 {
decimated = append(decimated, pCurr)
}
}
decimated = append(decimated, points[len(points)-1])
return decimated
}
// ContainsPoint checks if a point is inside a polygon using ray casting
func (poly *Polygon) ContainsPoint(p Vector3) bool {
if len(poly.Points) < 3 {
return false
}
inside := false
n := len(poly.Points)
for i, j := 0, n-1; i < n; j, i = i, i+1 {
xi, yi := poly.Points[i].X, poly.Points[i].Y
xj, yj := poly.Points[j].X, poly.Points[j].Y
if ((yi > p.Y) != (yj > p.Y)) &&
(p.X < (xj-xi)*(p.Y-yi)/(yj-yi)+xi) {
inside = !inside
}
}
return inside
}
// ClipPolygonToPolygon clips subject polygon to clip polygon using Sutherland-Hodgman algorithm
func (p *Polygon) ClipPolygonToPolygon(subject *Polygon) *Polygon {
if len(subject.Points) < 3 || len(p.Points) < 3 {
return nil
}
output := &Polygon{Points: make([]Vector3, len(subject.Points))}
copy(output.Points, subject.Points)
// Clip against each edge of the clip polygon
n := len(p.Points)
for i := 0; i < n; i++ {
if len(output.Points) == 0 {
return nil
}
j := (i + 1) % n
edge := LineSegment{Start: p.Points[i], End: p.Points[j]}
output = output.ClipPolygonByEdge(edge)
}
if len(output.Points) < 3 {
return nil
}
return output
}
// ClipLineToPolygon clips a line segment to a polygon boundary
// Returns all segments that lie within the polygon
func (poly *Polygon) ClipLineToPolygon(line LineSegment) []LineSegment {
if len(poly.Points) < 3 {
return nil
}
// Find all intersection points between the line and polygon edges
type intersection struct {
point Vector3
t float64 // Parameter along line (0 = start, 1 = end)
}
intersections := []intersection{}
n := len(poly.Points)
// Check intersections with each polygon edge
for i := 0; i < n; i++ {
j := (i + 1) % n
edge := LineSegment{Start: poly.Points[i], End: poly.Points[j]}
// Find intersection between line and edge
p1, p2 := line.Start, line.End
p3, p4 := edge.Start, edge.End
denom := (p1.X-p2.X)*(p3.Y-p4.Y) - (p1.Y-p2.Y)*(p3.X-p4.X)
if math.Abs(denom) < 1e-10 {
continue // Parallel or coincident
}
t := ((p1.X-p3.X)*(p3.Y-p4.Y) - (p1.Y-p3.Y)*(p3.X-p4.X)) / denom
u := -((p1.X-p2.X)*(p1.Y-p3.Y) - (p1.Y-p2.Y)*(p1.X-p3.X)) / denom
// Check if intersection is within both segments
if t >= 0 && t <= 1 && u >= 0 && u <= 1 {
intersections = append(intersections, intersection{
point: Vector3{
X: p1.X + t*(p2.X-p1.X),
Y: p1.Y + t*(p2.Y-p1.Y),
Z: p1.Z + t*(p2.Z-p1.Z), // Preserve Z along the line
},
t: t,
})
}
}
// Check if start and end points are inside
startInside := poly.ContainsPoint(line.Start)
endInside := poly.ContainsPoint(line.End)
// Build result segments
var result []LineSegment
if len(intersections) == 0 {
// No intersections
if startInside && endInside {
// Entire line is inside
result = append(result, line)
}
// Otherwise, line is entirely outside
return result
}
// Sort intersections by t parameter
for i := 0; i < len(intersections)-1; i++ {
for j := i + 1; j < len(intersections); j++ {
if intersections[i].t > intersections[j].t {
intersections[i], intersections[j] = intersections[j], intersections[i]
}
}
}
// Build segments from intersections
points := []Vector3{}
if startInside {
points = append(points, line.Start)
}
for _, inter := range intersections {
points = append(points, inter.point)
}
if endInside {
points = append(points, line.End)
}
// Create segments from consecutive pairs of points
// Only include segments whose midpoint is inside the polygon
for i := 0; i < len(points)-1; i++ {
seg := LineSegment{Start: points[i], End: points[i+1]}
// Check midpoint
mid := Vector3{
X: (seg.Start.X + seg.End.X) / 2,
Y: (seg.Start.Y + seg.End.Y) / 2,
}
if poly.ContainsPoint(mid) {
result = append(result, seg)
}
}
return result
}
// ClipPolygonByEdge clips a polygon against a single edge
func (poly *Polygon) ClipPolygonByEdge(edge LineSegment) *Polygon {
if len(poly.Points) == 0 {
return poly
}
result := &Polygon{Points: []Vector3{}}
// Edge normal (perpendicular, pointing inward to clip region)
edgeVec := edge.End.Sub(edge.Start)
normal := Vector3{X: -edgeVec.Y, Y: edgeVec.X}
n := len(poly.Points)
for i := 0; i < n; i++ {
current := poly.Points[i]
next := poly.Points[(i+1)%n]
// Check which side of the edge each point is on
currentVec := current.Sub(edge.Start)
nextVec := next.Sub(edge.Start)
currentInside := normal.Dot(currentVec) >= 0
nextInside := normal.Dot(nextVec) >= 0
if currentInside {
result.Points = append(result.Points, current)
}
// If edge crosses the clip edge, find intersection
if currentInside != nextInside {
// Find intersection point
segLine := LineSegment{Start: current, End: next}
t, valid := segLine.IntersectLines(edge)
if valid && t >= 0 && t <= 1 {
intersection := Vector3{
X: current.X + t*(next.X-current.X),
Y: current.Y + t*(next.Y-current.Y),
}
result.Points = append(result.Points, intersection)
}
}
}
return result
}
// removeSelfIntersections removes self-intersecting parts of a polygon
// This is a simplified implementation - a full version would use a sweep-line algorithm
func removeSelfIntersections(poly *Polygon) *Polygon {
if len(poly.Points) < 3 {
return poly
}
n := len(poly.Points)
edges := make([]LineSegment, n)
for i := 0; i < n; i++ {
j := (i + 1) % n
edges[i] = LineSegment{Start: poly.Points[i], End: poly.Points[j]}
}
// Find any self-intersections
// For simplicity, just check each edge against all non-adjacent edges
intersectionFound := false
for i := 0; i < n && !intersectionFound; i++ {
for j := i + 2; j < n && !intersectionFound; j++ {
// Skip adjacent edges
if (j+1)%n == i {
continue
}
_, intersects := edges[i].IntersectSegments(edges[j])
if intersects {
intersectionFound = true
break
}
}
}
// If no self-intersections, return as-is
if !intersectionFound {
return poly
}
// If self-intersections found, try to recover by removing problematic vertices
// This is a very simplified approach - just keep points that maintain a valid polygon
filtered := []Vector3{poly.Points[0]}
for i := 1; i < n; i++ {
// Only add point if it's not too close to the previous point
prev := filtered[len(filtered)-1]
curr := poly.Points[i]
dist := prev.Distance(curr)
if dist > 1e-6 {
filtered = append(filtered, curr)
}
}
if len(filtered) < 3 {
return poly // Return original if filtering made it invalid
}
return &Polygon{Points: filtered}
}
// IntersectLine finds all x-coordinates where a horizontal line at height y intersects the polygon
func (p *Polygon) IntersectLine(y float64) []float64 {
var intersections []float64
n := len(p.Points)
if n < 2 {
return intersections
}
for i := 0; i < n; i++ {
j := (i + 1) % n
p1 := p.Points[i]
p2 := p.Points[j]
// Check if the edge crosses the horizontal line at y
// Use < on one side and >= on the other to avoid double-counting vertices
if (p1.Y <= y && p2.Y > y) || (p2.Y <= y && p1.Y > y) {
// Skip horizontal edges (both points at approximately same Y)
if math.Abs(p2.Y-p1.Y) < 1e-10 {
continue
}
// Calculate x-coordinate of intersection
t := (y - p1.Y) / (p2.Y - p1.Y)
x := p1.X + t*(p2.X-p1.X)
intersections = append(intersections, x)
}
}
return intersections
}
package model
import (
"math"
"sort"
)
// Slice represents a single horizontal layer of the sliced model
type Slice struct {
Z float64 // Z height of this slice
LayerIndex int // Index of this layer (0-based)
Segments []LineSegment // Line segments from triangle intersections
Polygons []Polygon // Closed polygons formed from segments
// Processed print paths
Perimeters [][]Vector3 // Outer walls (multiple perimeters)
InfillLines []LineSegment // Infill line segments
ContinuousPaths []ContinuousPath // Optimized continuous paths (perimeters + infill)
IsTopLayer bool // True if this is a top solid layer
IsBottomLayer bool // True if this is a bottom solid layer
}
// BuildPolygons connects segments into closed polygons
func (s *Slice) BuildPolygons(tolerance float64) {
if len(s.Segments) == 0 {
return
}
// Copy segments as we'll be removing them as we build polygons
remainingSegments := make([]LineSegment, len(s.Segments))
copy(remainingSegments, s.Segments)
// Build polygons by connecting segments
for len(remainingSegments) > 0 {
// Start a new contour with the first remaining segment
contour := Polygon{
Points: make([]Vector3, 0),
IsClosed: false,
IsHole: false,
}
// Take first segment
current := remainingSegments[0]
remainingSegments = remainingSegments[1:]
contour.Points = append(contour.Points, current.Start, current.End)
// Try to extend the contour
maxIterations := len(remainingSegments) + 1
iterations := 0
for len(remainingSegments) > 0 && iterations < maxIterations {
iterations++
// Try to find a segment that connects to the end of our contour
lastPoint := contour.Points[len(contour.Points)-1]
firstPoint := contour.Points[0]
foundConnection := false
for i, seg := range remainingSegments {
// Check if segment connects to end of contour
if seg.Start.Equals(lastPoint) {
contour.Points = append(contour.Points, seg.End)
remainingSegments = append(remainingSegments[:i], remainingSegments[i+1:]...)
foundConnection = true
break
} else if seg.End.Equals(lastPoint) {
contour.Points = append(contour.Points, seg.Start)
remainingSegments = append(remainingSegments[:i], remainingSegments[i+1:]...)
foundConnection = true
break
}
}
// Check if contour is closed
if len(contour.Points) > 2 {
if lastPoint.Equals(firstPoint) ||
contour.Points[len(contour.Points)-1].Equals(firstPoint) {
contour.IsClosed = true
break
}
}
if !foundConnection {
break
}
}
// Add contour if it has enough points
if len(contour.Points) >= 3 {
// Ensure consistent winding order - make all polygons counter-clockwise (positive area)
area := contour.GetArea()
if area < 0 {
// Clockwise - reverse to make counter-clockwise
contour.Reverse()
}
s.Polygons = append(s.Polygons, contour)
}
}
// Determine which contours are holes (inside other contours)
s.ClassifyPolygons()
}
// ClassifyPolygons determines which contours are holes based on containment
func (s *Slice) ClassifyPolygons() {
// Sort contours by absolute area (largest first)
sort.Slice(s.Polygons, func(i, j int) bool {
return math.Abs(s.Polygons[i].GetArea()) > math.Abs(s.Polygons[j].GetArea())
})
// A polygon is a hole if it's contained within another polygon
// We check containment by testing if a point from the polygon is inside another
for i := range s.Polygons {
if !s.Polygons[i].IsClosed || len(s.Polygons[i].Points) < 3 {
continue
}
s.Polygons[i].IsHole = false
testPoint := s.Polygons[i].Points[0]
// Check if this polygon is contained in any larger polygon
for j := range s.Polygons {
if i == j || !s.Polygons[j].IsClosed || len(s.Polygons[i].Points) < 3 || s.Polygons[i].Points[0].Z != s.Polygons[j].Points[0].Z {
continue
}
// Only check larger polygons (already sorted by area)
if j < i && s.Polygons[j].ContainsPoint(testPoint) {
s.Polygons[i].IsHole = true
break
}
}
}
}
// ===== Helper functions =====
// IntersectZPlane finds where a triangle intersects a horizontal plane at height z
// Returns a line segment if the triangle crosses the plane (2 edge intersections)
func (t Triangle) IntersectZPlane(z float64) (LineSegment, bool) {
// Check each edge of the triangle for intersection with plane
edges := []LineSegment{
{t.V1, t.V2},
{t.V2, t.V3},
{t.V3, t.V1},
}
// Check if edge crosses the plane
intersections := make([]Vector3, 0, 2)
for _, edge := range edges {
if point, ok := edge.IntersectZPlane(z); ok {
intersections = append(intersections, point)
}
}
// A valid intersection should have exactly 2 points
if len(intersections) == 2 {
return LineSegment{
Start: intersections[0],
End: intersections[1],
}, true
}
return LineSegment{}, false
}
// ContainsPoint checks if a point is inside the slice's solid geometry
func (s *Slice) ContainsPoint(p Vector3) bool {
inShell := false
for _, poly := range s.Polygons {
if !poly.IsHole && poly.ContainsPoint(p) {
inShell = true
break
}
}
if !inShell {
return false
}
for _, poly := range s.Polygons {
if poly.IsHole && poly.ContainsPoint(p) {
return false
}
}
return true
}
package model
import "fmt"
type InfillType int
const (
InfillLine InfillType = iota
InfillGrid
InfillTriHexagon
InfillCross
InfillHoneycombContinuous
InfillGyroid
// Full infills for top and bottom
InfillLineFull
InfillRectilinearFull
InfillConcentricFull
)
// ShellOrder controls the order in which wall perimeters are printed
type ShellOrder int
const (
ShellInsideOut ShellOrder = iota // Print inner walls first, then outer (default)
ShellOutsideIn // Print outer wall first, then inner
)
// StartPointStrategy controls where each layer's printing starts
type StartPointStrategy int
const (
StartPointNearest StartPointStrategy = iota // Start nearest to previous end position
StartPointRandom // Random start point per layer
)
// GCodeFlavor selects the G-code dialect for export
type GCodeFlavor int
const (
GCodeMarlin GCodeFlavor = iota // Marlin / Prusa firmware
GCodeRepRap // RepRap firmware
GCodeKlipper // Klipper firmware
)
// SupportType controls whether support structures are generated
type SupportType int
const (
SupportNone SupportType = iota // No support generation
SupportAuto // Automatic linear support
SupportTree // Tree/Stump support (widens at base)
)
// SupportPlacement defines where supports can be placed
type SupportPlacement int
const (
SupportEverywhere SupportPlacement = iota
SupportTouchingBuildplate
)
// PathCategory distinguishes path types for per-category speed/color/visibility
type PathCategory int
const (
CategoryOuterWall PathCategory = iota // Outermost perimeter
CategoryInnerWall // Inner perimeters
CategoryInfill // Sparse infill
CategorySolidInfill // Top/bottom solid infill
CategorySupport // Support structures (future)
CategorySkirt // Skirt lines
CategoryBrim // Brim lines
CategoryTravel // Non-extrusion travel moves
)
// SliceConfig contains parameters for slicing
type SliceConfig struct {
LayerHeight float64 `json:"layer_height"` // Height of each layer in mm
FirstLayer float64 `json:"first_layer"` // Height of first layer (often thicker)
Tolerance float64 `json:"tolerance"` // Tolerance for connecting segments
// Wall/Perimeter settings
WallThickness float64 `json:"wall_thickness"` // Total wall thickness in mm
ShellCount int `json:"shell_count"` // Number of perimeters
LineWidth float64 `json:"line_width"` // Width of a single extrusion line in mm
ShellOrder ShellOrder `json:"shell_order"` // Inside-out or outside-in wall order
// Top/Bottom settings
TopLayers int `json:"top_layers"` // Number of solid top layers
TopLayerType InfillType `json:"top_layer_type"` // Infill pattern for top layers
BottomLayers int `json:"bottom_layers"` // Number of solid bottom layers
BottomLayerType InfillType `json:"bottom_layer_type"` // Infill pattern for bottom layers
// Infill settings
InfillDensity float64 `json:"infill_density"` // Infill density 0.0 to 1.0 (0% to 100%)
InfillAngle float64 `json:"infill_angle"` // Angle of infill lines in degrees
InfillType InfillType `json:"infill_type"` // Pattern: "lines", "grid", "triangles", "honeycomb"
// Printer Speed settings (mm/s)
InfillSpeed float64 `json:"infill_speed"`
WallSpeed float64 `json:"wall_speed"`
OuterShellSpeed float64 `json:"outer_shell_speed"` // Slower outer perimeter speed
TravelSpeed float64 `json:"travel_speed"`
FirstLayerSpeed float64 `json:"first_layer_speed"` // Speed for first layer
MinSpeed float64 `json:"min_speed"` // Minimum print speed (for min layer time)
// Extrusion settings
FlowMultiplier float64 `json:"flow_multiplier"` // Extrusion multiplier (1.0 = 100%)
// Retraction settings
RetractionDist float64 `json:"retraction_dist"` // mm
RetractionSpeed float64 `json:"retraction_speed"` // mm/s
RetractionZHop float64 `json:"retraction_zhop"` // Z lift on retract (mm)
RetractionPrime float64 `json:"retraction_prime"` // Extra prime after unretract (mm)
// Cooling settings
FanSpeed int `json:"fan_speed"` // 0-255
FanOnLayer int `json:"fan_on_layer"` // Layer number when fan activates
MinLayerTime float64 `json:"min_layer_time"` // Min seconds per layer (slows down if too fast)
// Skirt/Brim settings
SkirtCount int `json:"skirt_count"` // Number of skirt lines
SkirtOffset float64 `json:"skirt_offset"` // Distance from model (mm)
BrimCount int `json:"brim_count"` // Number of brim lines (directly attached)
// Support settings
SupportType SupportType `json:"support_type"` // None or Auto
SupportPlacement SupportPlacement `json:"support_placement"` // Everywhere or Touching Buildplate
SupportDensity float64 `json:"support_density"` // 0.0-1.0 density of support infill
SupportAngle float64 `json:"support_angle"` // Overhang threshold in degrees
SupportZGap float64 `json:"support_z_gap"` // Gap between support top and model (mm)
SupportXYGap float64 `json:"support_xy_gap"` // XY gap between support and model (mm)
SupportSpeed float64 `json:"support_speed"` // Print speed for support (mm/s)
SupportInterfaceLayers int `json:"support_interface_layers"` // Number of interface layers
SupportTreeBranchDiameter float64 `json:"support_tree_branch_diameter"` // Diameter of the spawned tips (mm)
SupportTreeTrunkDiameter float64 `json:"support_tree_trunk_diameter"` // Max diameter of merged trunk (mm)
// Raft settings
RaftLayers int `json:"raft_layers"` // Number of raft layers (0 = disabled)
RaftOffset float64 `json:"raft_offset"` // Extra offset around model for raft (mm)
// Vase mode
VaseMode bool `json:"vase_mode"` // Spiral single-wall mode
// Machine settings
PrinterModel string `json:"printer_model"` // Target printer model
NozzleDiameter float64 `json:"nozzle_diameter"` // Nozzle diameter in mm
StartPoint StartPointStrategy `json:"start_point"` // Seam placement strategy
BuildVolumeX float64 `json:"build_volume_x"` // Build volume X in mm
BuildVolumeY float64 `json:"build_volume_y"` // Build volume Y in mm
BuildVolumeZ float64 `json:"build_volume_z"` // Build volume Z in mm
// Material settings
MaterialName string `json:"material_name"` // e.g. PLA, ABS
MaterialColor string `json:"material_color"` // HEX or name
NozzleTemp float64 `json:"nozzle_temp"`
BedTemp float64 `json:"bed_temp"`
// G-code settings
GCodeFlavor GCodeFlavor `json:"gcode_flavor"` // G-code dialect
StartGCode string `json:"start_gcode"` // Custom start G-code
EndGCode string `json:"end_gcode"` // Custom end G-code
// Positions
StartPosition Vector3 `json:"start_position"` // Starting position for printing
EndPosition Vector3 `json:"end_position"` // Ending position for printing
// Advanced / Slicing Details
InfillOverlap float64 `json:"infill_overlap"` // Overlap percentage for infill (0.0 to 1.0)
FirstLayerLineWidth float64 `json:"first_layer_line_width"` // Line width for first layer in mm
}
// DefaultStartGCode returns template start G-code for Marlin-compatible printers
func DefaultStartGCode(config *SliceConfig) string {
return "; Start G-code\n" +
"G28 ; Home all axes\n" +
"G92 E0 ; Reset extruder\n" +
"G1 Z5 F3000 ; Lift nozzle\n" +
"M104 S" + floatStr(config.NozzleTemp) + " ; Set nozzle temp\n" +
"M140 S" + floatStr(config.BedTemp) + " ; Set bed temp\n" +
"M190 S" + floatStr(config.BedTemp) + " ; Wait for bed temp\n" +
"M109 S" + floatStr(config.NozzleTemp) + " ; Wait for nozzle temp\n" +
"G1 Z0.3 F3000 ; Move to start height\n" +
"G92 E0 ; Reset extruder\n"
}
// DefaultEndGCode returns template end G-code for Marlin-compatible printers
func DefaultEndGCode() string {
return "; End G-code\n" +
"G91 ; Relative positioning\n" +
"G1 E-2 F2700 ; Retract\n" +
"G1 Z10 F3000 ; Lift nozzle\n" +
"G90 ; Absolute positioning\n" +
"G28 X Y ; Home X and Y\n" +
"M104 S0 ; Turn off nozzle\n" +
"M140 S0 ; Turn off bed\n" +
"M106 S0 ; Turn off fan\n" +
"M84 ; Disable motors\n"
}
// floatStr formats a float64 to a clean string for G-code
func floatStr(f float64) string {
s := fmt.Sprintf("%.1f", f)
return s
}
// NewSliceConfig creates a default slicing configuration
func NewSliceConfig() *SliceConfig {
config := &SliceConfig{
LayerHeight: 0.2, // 0.2mm standard layer height
FirstLayer: 0.3, // 0.3mm first layer
Tolerance: 0.0001, // 0.1 micron tolerance
WallThickness: 1.2, // 1.2mm total width
ShellCount: 3, // 3 perimeters
LineWidth: 0.4, // 0.4mm standard nozzle width
ShellOrder: ShellInsideOut,
TopLayers: 4, // 4 solid top layers
BottomLayers: 3, // 3 solid bottom layers
InfillDensity: 0.2, // 20% infill
InfillAngle: 45.0, // 45 degree infill angle
InfillType: InfillLine,
InfillSpeed: 60.0, // 60 mm/s
WallSpeed: 40.0, // 40 mm/s
OuterShellSpeed: 25.0, // 25 mm/s (slower for quality)
TravelSpeed: 120.0, // 120 mm/s
FirstLayerSpeed: 20.0, // 20 mm/s (slow first layer)
MinSpeed: 10.0, // 10 mm/s minimum
FlowMultiplier: 1.0, // 100% flow
NozzleTemp: 200.0, // 200°C (PLA)
BedTemp: 60.0, // 60°C
RetractionDist: 5.0, // 5mm
RetractionSpeed: 40.0, // 40 mm/s
RetractionZHop: 0.0, // No Z-hop by default
RetractionPrime: 0.0, // No extra prime by default
FanSpeed: 255, // Full fan speed
FanOnLayer: 2, // Fan on from layer 2
MinLayerTime: 5.0, // 5 seconds minimum
SkirtCount: 3, // 3 skirt lines
SkirtOffset: 3.0, // 3mm from model
BrimCount: 0, // Default to no brim
SupportType: SupportTree,
SupportPlacement: SupportEverywhere,
SupportDensity: 0.15, // 15% support infill
SupportAngle: 45.0, // 45 degree overhang threshold
SupportZGap: 0.2, // 0.2mm gap at top of support
SupportXYGap: 0.4, // 0.4mm XY gap
SupportSpeed: 40.0, // 40 mm/s for support
SupportInterfaceLayers: 3,
SupportTreeBranchDiameter: 2.0,
SupportTreeTrunkDiameter: 12.0,
RaftLayers: 0, // No raft by default
RaftOffset: 3.0, // 3mm raft offset
VaseMode: false, // Vase mode off by default
PrinterModel: "Generic FDM",
NozzleDiameter: 0.4, // 0.4mm standard nozzle
StartPoint: StartPointNearest,
BuildVolumeX: 220.0, // 220mm X
BuildVolumeY: 220.0, // 220mm Y
BuildVolumeZ: 250.0, // 250mm Z
MaterialName: "PLA",
MaterialColor: "#FFFFFF",
GCodeFlavor: GCodeMarlin,
StartPosition: Vector3{X: 0, Y: 0, Z: 1000},
EndPosition: Vector3{X: 0, Y: 0, Z: 1000},
InfillOverlap: 0.15, // 15% overlap
FirstLayerLineWidth: 0.4,
}
config.StartGCode = DefaultStartGCode(config)
config.EndGCode = DefaultEndGCode()
return config
}
// CalculateLayerHeights computes the Z heights for each layer
func (s *SliceConfig) CalculateLayerHeights(minZ, maxZ float64) []float64 {
heights := make([]float64, 0)
// Start with first layer
z := minZ + s.FirstLayer
heights = append(heights, z)
// Add remaining layers
for z < maxZ {
z += s.LayerHeight
if z <= maxZ {
heights = append(heights, z)
}
}
return heights
}
package model
import (
"fmt"
"math"
)
// Triangle represents a triangular facet in the STL model
type Triangle struct {
Normal Vector3 `json:"normal"`
V1 Vector3 `json:"v1"`
V2 Vector3 `json:"v2"`
V3 Vector3 `json:"v3"`
// Attribute byte count (used in binary STL, often for color)
Attr uint16 `json:"attr"`
}
// ComputeNormal calculates the normal vector from the vertices using the right-hand rule
func (t *Triangle) ComputeNormal() Vector3 {
edge1 := t.V2.Sub(t.V1)
edge2 := t.V3.Sub(t.V1)
return edge1.Cross(edge2).Normalize()
}
// Area calculates the area of the triangle
func (t *Triangle) Area() float64 {
edge1 := t.V2.Sub(t.V1)
edge2 := t.V3.Sub(t.V1)
cross := edge1.Cross(edge2)
return cross.Length() / 2.0
}
// IsDegenerate checks if the triangle is degenerate (vertices too close or collinear)
// A triangle is degenerate if:
// 1. Any two vertices are essentially at the same location (< 1e-6 apart)
// 2. The area is effectively zero (collinear vertices)
func (t *Triangle) IsDegenerate(tolerance float64) bool {
// Use a very small epsilon for degenerate detection (0.001mm = 1 micron)
// This is independent of the user's tolerance parameter
const epsilon = 1e-6
// Check if any two vertices are too close
edge1 := t.V2.Sub(t.V1)
edge2 := t.V3.Sub(t.V1)
edge3 := t.V3.Sub(t.V2)
// If any edge is shorter than epsilon, vertices are essentially at the same location
if edge1.Length() < epsilon || edge2.Length() < epsilon || edge3.Length() < epsilon {
return true
}
// Check if area is effectively zero (collinear vertices)
// For collinear vertices, the cross product magnitude (2*area) should be very small
// Use a relative check: area should be at least 1e-6 times the product of edge lengths
cross := edge1.Cross(edge2)
crossLength := cross.Length()
// Get maximum edge length for relative comparison
maxEdgeLength := edge1.Length()
if edge2.Length() > maxEdgeLength {
maxEdgeLength = edge2.Length()
}
if edge3.Length() > maxEdgeLength {
maxEdgeLength = edge3.Length()
}
// If cross product is extremely small relative to edge size, vertices are collinear
// This catches collinear vertices better than absolute epsilon
if crossLength < epsilon*maxEdgeLength {
return true
}
return false
}
// Key creates a unique key for the triangle based on its vertices
func (t *Triangle) Key(tolerance float64) string {
// Round vertices to tolerance to handle floating point precision
round := func(v Vector3) Vector3 {
scale := float64(1.0 / tolerance)
return Vector3{
math.Round(v.X*scale) / scale,
math.Round(v.Y*scale) / scale,
math.Round(v.Z*scale) / scale,
}
}
r1 := round(t.V1)
r2 := round(t.V2)
r3 := round(t.V3)
return fmt.Sprintf("%.6f,%.6f,%.6f-%.6f,%.6f,%.6f-%.6f,%.6f,%.6f",
r1.X, r1.Y, r1.Z, r2.X, r2.Y, r2.Z, r3.X, r3.Y, r3.Z)
}
func (t Triangle) String() string {
return fmt.Sprintf("Normal: %s, V1: %s, V2: %s, V3: %s, Attr: %d",
t.Normal.String(), t.V1.String(), t.V2.String(), t.V3.String(), t.Attr)
}
package model
import (
"fmt"
"math"
)
// Vector3 represents a 3D vector with X, Y, Z coordinates
type Vector3 struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// MarshalJSON truncates floating point coordinates to 3 decimal places to drastically reduce JSON byte footprint.
func (v Vector3) MarshalJSON() ([]byte, error) {
// 3 decimal places = 1 micrometer precision.
// We do not need more than this for gcode or 3D rendering.
str := fmt.Sprintf(`{"x":%.3f,"y":%.3f,"z":%.3f}`, v.X, v.Y, v.Z)
return []byte(str), nil
}
// Add adds two vectors
func (v Vector3) Add(other Vector3) Vector3 {
return Vector3{v.X + other.X, v.Y + other.Y, v.Z + other.Z}
}
// Sub subtracts two vectors
func (v Vector3) Sub(other Vector3) Vector3 {
return Vector3{v.X - other.X, v.Y - other.Y, v.Z - other.Z}
}
// Distance calculates the distance between two points
func (p Vector3) Distance(other Vector3) float64 {
dx := p.X - other.X
dy := p.Y - other.Y
dz := p.Z - other.Z
return math.Sqrt(dx*dx + dy*dy + dz*dz)
}
func (p Vector3) Equals(other Vector3) bool {
const epsilon = 1e-6
return p.Distance(other) < epsilon
}
// Scale multiplies the vector by a scalar
func (p Vector3) Scale(factor float64) Vector3 {
return Vector3{X: p.X * factor, Y: p.Y * factor, Z: p.Z * factor}
}
func (p Vector3) Rotate(angle float64) Vector3 {
s, c := math.Sincos(angle)
return Vector3{
X: p.X*c - p.Y*s,
Y: p.X*s + p.Y*c,
Z: p.Z,
}
}
// RotateAroundPoint rotates the vector around a center point by the given angle
func (p Vector3) RotateAroundPoint(center Vector3, angle float64) Vector3 {
// Translate to origin
dx := p.X - center.X
dy := p.Y - center.Y
// Rotate
cos := math.Cos(angle)
sin := math.Sin(angle)
rotatedX := dx*cos - dy*sin
rotatedY := dx*sin + dy*cos
// Translate back
return Vector3{
X: rotatedX + center.X,
Y: rotatedY + center.Y,
Z: p.Z,
}
}
// Length returns the length of the vector
func (p Vector3) Length() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y + p.Z*p.Z)
}
// Normalize returns a unit vector
func (p Vector3) Normalize() Vector3 {
length := p.Length()
if length == 0 {
return Vector3{0, 0, 0}
}
return Vector3{X: p.X / length, Y: p.Y / length, Z: p.Z / length}
}
// Perpendicular returns a perpendicular vector (rotated 90° counterclockwise)
func (p Vector3) Perpendicular() Vector3 {
return Vector3{X: -p.Y, Y: p.X, Z: p.Z}
}
// Cross computes the cross product of two vectors
func (v Vector3) Cross(other Vector3) Vector3 {
return Vector3{
v.Y*other.Z - v.Z*other.Y,
v.Z*other.X - v.X*other.Z,
v.X*other.Y - v.Y*other.X,
}
}
// Dot computes the dot product of two vectors
func (v Vector3) Dot(other Vector3) float64 {
return v.X*other.X + v.Y*other.Y + v.Z*other.Z
}
// PerpendicularDistanceToLine calculates the perpendicular distance from this point to a line segment
func (point Vector3) PerpendicularDistanceToLine(lineStart, lineEnd Vector3) float64 {
// Vector from lineStart to lineEnd
dx := lineEnd.X - lineStart.X
dy := lineEnd.Y - lineStart.Y
dz := lineEnd.Z - lineStart.Z
// If the line segment is actually a point
if dx == 0 && dy == 0 && dz == 0 {
return point.Distance(lineStart)
}
// Calculate the parameter t that represents the projection of point onto the line
// t = [(P-A) · (B-A)] / |B-A|²
t := ((point.X-lineStart.X)*dx + (point.Y-lineStart.Y)*dy + (point.Z-lineStart.Z)*dz) / (dx*dx + dy*dy + dz*dz)
// Clamp t to [0, 1] to ensure we're measuring to the line segment, not the infinite line
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
// Find the closest point on the line segment
closestPoint := Vector3{
X: lineStart.X + t*dx,
Y: lineStart.Y + t*dy,
Z: lineStart.Z + t*dz,
}
// Return the distance from the point to the closest point on the segment
return point.Distance(closestPoint)
}
func (v Vector3) String() string {
return fmt.Sprintf("(%.6f, %.6f, %.6f)", v.X, v.Y, v.Z)
}
package slicer
import (
"fmt"
"io"
"github.com/siherrmann/slicer/core"
"github.com/siherrmann/slicer/model"
)
type Slicer struct {
Config *model.SliceConfig // Slicing configuration
Model *model.BaseModel // Loaded model (STL or 3MF)
}
func NewSlicer() *Slicer {
return &Slicer{
Config: model.NewSliceConfig(), // Default config
}
}
// NewSlicerWithConfig creates a slicer with custom configuration
func NewSlicerWithConfig(config *model.SliceConfig) *Slicer {
return &Slicer{
Config: config,
}
}
func (s *Slicer) LoadSTLModel(r io.Reader, modelName string) (*model.BaseModel, error) {
// Load the STL model
stl, err := model.LoadSTL(r, modelName)
if err != nil {
return nil, fmt.Errorf("failed to load STL model: %w", err)
}
s.Model = &stl.BaseModel
return s.Model, nil
}
// Load3mfFile loads a 3MF file from a reader
func (s *Slicer) Load3mfFile(r io.Reader, modelName string) (*model.BaseModel, error) {
// Open the 3MF XML file via reader
bm, err := model.Load3MF(r, modelName)
if err != nil {
return nil, fmt.Errorf("failed to load 3MF file: %w", err)
}
s.Model = bm
if bm.SliceConfig != nil {
s.Config = bm.SliceConfig
}
return s.Model, nil
}
// Slice slices a model into layers
func (s *Slicer) Slice(bm *model.BaseModel) ([]*model.Slice, error) {
if bm == nil {
return nil, fmt.Errorf("cannot slice nil model")
}
// Perform the slicing
err := bm.Slice(s.Config)
if err != nil {
return nil, fmt.Errorf("failed to slice model: %w", err)
}
return bm.Slices, nil
}
// GeneratePrintPaths processes all slices to generate perimeters, infill, and classify layers
func (s *Slicer) GeneratePrintPaths(config *model.SliceConfig) ([]model.ContinuousPath, error) {
if s.Model == nil {
return nil, fmt.Errorf("no model loaded")
}
// Step 1: Clean the model to ensure it's ready for slicing
s.Model = s.Model.CleanBounds()
s.Model.SliceConfig = config
// Step 2: If the model hasn't been sliced yet, slice it now
if len(s.Model.Slices) == 0 {
err := s.Model.Slice(config)
if err != nil {
return nil, err
}
}
s.Model.ClassifyTopBottomLayers(config)
// Step 3: Generate full print paths for all layers (perimeters, infill, etc.)
paths := core.GenerateFullSTLPath(s.Model, *config)
// Step 4: Globally simplify all geometry to prevent massive JSON payload bloats.
// RDP simplification annihilates collinear/dense points preserving rendering and print quality.
var cleanPaths []model.ContinuousPath
for _, p := range paths {
if p.PathType == model.PathExtrusion && len(p.Segments) > 3 {
// Convert single path to array and apply RDP
merged := core.CleanFullPaths([]model.ContinuousPath{p}, 0.05)
if len(merged.Segments) > 0 {
cleanPaths = append(cleanPaths, merged)
}
} else {
cleanPaths = append(cleanPaths, p)
}
}
return cleanPaths, nil
}
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
_ "embed"
"encoding/json"
"fmt"
"github.com/siherrmann/slicer/model"
)
//go:embed viewer.js
var viewerJs string
func marshal(anyMap interface{}) string {
jsonBytes, err := json.Marshal(anyMap)
if err != nil {
return "{}"
}
return string(jsonBytes)
}
// SlicerSidebar renders ONLY the sidebar + update scripts.
// HTMX swaps the outer container div, never the canvas.
func SlicerSidebar(bm *model.BaseModel, paths []model.ContinuousPath, config *model.SliceConfig) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"sidebar-container\" style=\"width: 320px; display: flex; flex-direction: column;\"><form id=\"slice-form\" hx-post=\"/slice\" hx-target=\"#sidebar-container\" hx-swap=\"outerHTML\" class=\"viewer-sidebar\" style=\"width: 100%; height: 100%; background: rgba(255,255,255,0.95); border-left: 1px solid #ddd; display: flex; flex-direction: column; box-shadow: -2px 0 10px rgba(0,0,0,0.1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; z-index: 10;\"><div style=\"padding: 15px; border-bottom: 1px solid #ddd; background: #f8f9fa;\"><h2 style=\"margin: 0; font-size: 16px; color: #333;\">Slicer Einstellungen</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bm != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div style=\"font-size: 11px; color: #666; margin-top: 4px;\">Modell: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(bm.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 36, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div style=\"flex: 1; overflow-y: auto; padding: 15px;\"><!-- Ansicht & Analyse --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;\">Ansicht & Analyse</label><div style=\"display: flex; gap: 5px; margin-bottom: 15px;\"><button type=\"button\" id=\"view-model-btn\" onclick=\"window.setViewMode('model')\" style=\"flex: 1; padding: 8px; border: 1px solid #ddd; background: #4CAF50; color: white; border-radius: 4px; cursor: pointer; font-size: 12px; transition: all 0.2s;\">Modell</button> <button type=\"button\" id=\"view-print-btn\" onclick=\"window.setViewMode('print')\" style=\"flex: 1; padding: 8px; border: 1px solid #ddd; background: white; color: #666; border-radius: 4px; cursor: pointer; font-size: 12px; transition: all 0.2s;\">Druck</button></div><label for=\"cut-slider\" style=\"display: block; font-size: 11px; color: #666; margin-bottom: 5px;\">Z-Schnitt (Vorschau)</label> <input type=\"range\" id=\"cut-slider\" min=\"0\" max=\"1\" step=\"0.001\" value=\"1\" style=\"width: 100%; margin-bottom: 5px;\" oninput=\"window.updateCutDepth(parseFloat(this.value))\"></div><!-- Layers & Shells --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Layers & Shells</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Schichthöhe (mm)</label> <input type=\"number\" name=\"layer_height\" step=\"0.05\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", config.LayerHeight))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 56, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Erste Schicht</label> <input type=\"number\" name=\"first_layer\" step=\"0.05\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", config.FirstLayer))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 60, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Wandlinien (Count)</label> <input type=\"number\" name=\"shell_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.ShellCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 64, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Linienbreite</label> <input type=\"number\" name=\"line_width\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.LineWidth))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 68, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Wandreihenfolge</label> <select name=\"shell_order\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; background: white;\"><option value=\"0\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.ShellOrder == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">Innen → Außen</option> <option value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.ShellOrder == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Außen → Innen</option></select></div></div></div><!-- Infill --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Infill</label> <label style=\"display: block; font-size: 10px; color: #888;\">Dichte (%)</label> <input type=\"range\" name=\"infill_density\" min=\"0\" max=\"1\" step=\"0.01\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", config.InfillDensity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 83, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" style=\"width: 100%; margin-bottom: 10px;\"><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Muster</label> <select name=\"infill_type\" style=\"width: 100%; padding: 6px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; background: white;\"><option value=\"0\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.InfillType == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Lines</option> <option value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.InfillType == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Grid</option> <option value=\"2\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.InfillType == 2 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Tri-Hexagon</option> <option value=\"5\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.InfillType == 5 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Gyroid</option> <option value=\"4\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.InfillType == 4 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Honeycomb</option></select></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Winkel (°)</label> <input type=\"number\" name=\"infill_angle\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.InfillAngle))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 97, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div><!-- Speeds --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Geschwindigkeit (mm/s)</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Infill</label> <input type=\"number\" name=\"infill_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.InfillSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 107, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Wände (innen)</label> <input type=\"number\" name=\"wall_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.WallSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 111, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Außenwand</label> <input type=\"number\" name=\"outer_shell_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.OuterShellSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 115, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Travel</label> <input type=\"number\" name=\"travel_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.TravelSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 119, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Erste Schicht</label> <input type=\"number\" name=\"first_layer_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.FirstLayerSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 123, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Minimum</label> <input type=\"number\" name=\"min_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.MinSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 127, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Flow Multiplikator</label> <input type=\"number\" name=\"flow_multiplier\" step=\"0.01\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", config.FlowMultiplier))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 131, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div><!-- Temperature & Cooling --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Temperatur & Kühlung</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Nozzle (°C)</label> <input type=\"number\" name=\"nozzle_temp\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.NozzleTemp))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 141, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Bed (°C)</label> <input type=\"number\" name=\"bed_temp\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.BedTemp))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 145, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Lüfter (0-255)</label> <input type=\"number\" name=\"fan_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.FanSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 149, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Lüfter ab Layer</label> <input type=\"number\" name=\"fan_on_layer\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.FanOnLayer))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 153, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Min. Layerzeit (s)</label> <input type=\"number\" name=\"min_layer_time\" step=\"0.5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.MinLayerTime))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 157, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div><!-- Retraction --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Rückzug (Retraction)</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Distanz (mm)</label> <input type=\"number\" name=\"retraction_dist\" step=\"0.5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.RetractionDist))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 167, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Geschw. (mm/s)</label> <input type=\"number\" name=\"retraction_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.RetractionSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 171, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Z-Hop (mm)</label> <input type=\"number\" name=\"retraction_zhop\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.RetractionZHop))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 175, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Nachdrücken (mm)</label> <input type=\"number\" name=\"retraction_prime\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.RetractionPrime))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 179, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div><!-- Skirt & Brim --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Haftung (Skirt & Brim)</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Skirt Linien</label> <input type=\"number\" name=\"skirt_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.SkirtCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 189, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Skirt Abstand</label> <input type=\"number\" name=\"skirt_offset\" step=\"0.5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.SkirtOffset))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 193, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Brim Linien</label> <input type=\"number\" name=\"brim_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.BrimCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 197, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div><!-- Machine / Advanced --><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Stützstrukturen (Support)</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Support Typ</label> <select name=\"support_type\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; background: white;\"><option value=\"0\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.SupportType == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Keine</option> <option value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.SupportType == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Normal (Linear)</option> <option value=\"2\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.SupportType == 2 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Tree (Stump)</option></select></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Dichte (0-1)</label> <input type=\"number\" name=\"support_density\" step=\"0.05\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", config.SupportDensity))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 215, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Winkel (°)</label> <input type=\"number\" name=\"support_angle\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.SupportAngle))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 219, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Z-Abstand (mm)</label> <input type=\"number\" name=\"support_z_gap\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.SupportZGap))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 223, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">XY-Abstand (mm)</label> <input type=\"number\" name=\"support_xy_gap\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.SupportXYGap))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 227, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Support Geschw. (mm/s)</label> <input type=\"number\" name=\"support_speed\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.SupportSpeed))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 231, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Tree Ast-Durchmesser (mm)</label> <input type=\"number\" name=\"support_tree_branch_diameter\" step=\"0.5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.SupportTreeBranchDiameter))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 235, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\" title=\"Durchmesser der neuen Äste am Modell\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Tree Stamm-Durchmesser (mm)</label> <input type=\"number\" name=\"support_tree_trunk_diameter\" step=\"1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.SupportTreeTrunkDiameter))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 239, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\" title=\"Maximaler Durchmesser der dicken Stämme am Boden\"></div></div></div><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Raft & Vasen-Modus</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Raft Schichten</label> <input type=\"number\" name=\"raft_layers\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", config.RaftLayers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 248, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Raft Abstand (mm)</label> <input type=\"number\" name=\"raft_offset\" step=\"0.5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.RaftOffset))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 252, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: flex; align-items: center; gap: 8px; font-size: 12px; color: #555; cursor: pointer;\"><input type=\"checkbox\" name=\"vase_mode\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.VaseMode {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " style=\"width: 16px; height: 16px;\"> Vasen-Modus (Spiral-Einwand)</label></div></div></div><div class=\"settings-group\" style=\"margin-bottom: 20px;\"><label style=\"display: block; font-size: 12px; font-weight: bold; color: #555; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 3px;\">Maschine & Erweitert</label><div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 10px;\"><div><label style=\"display: block; font-size: 10px; color: #888;\">Düsendurchm. (mm)</label> <input type=\"number\" name=\"nozzle_diameter\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", config.NozzleDiameter))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 267, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">G-Code Flavor</label> <select name=\"gcode_flavor\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; background: white;\"><option value=\"0\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.GCodeFlavor == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Marlin</option> <option value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.GCodeFlavor == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">RepRap</option> <option value=\"2\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.GCodeFlavor == 2 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Klipper</option></select></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Bauraum X (mm)</label> <input type=\"number\" name=\"build_volume_x\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.BuildVolumeX))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 279, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div><label style=\"display: block; font-size: 10px; color: #888;\">Bauraum Y (mm)</label> <input type=\"number\" name=\"build_volume_y\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.BuildVolumeY))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 283, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div><div style=\"grid-column: span 2;\"><label style=\"display: block; font-size: 10px; color: #888;\">Bauraum Z (mm)</label> <input type=\"number\" name=\"build_volume_z\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", config.BuildVolumeZ))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/viewer.templ`, Line: 287, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" style=\"width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px;\"></div></div></div></div><div style=\"padding: 15px; border-top: 1px solid #ddd; background: #f8f9fa; display: flex; flex-direction: column; gap: 8px;\"><button type=\"submit\" id=\"slice-btn\" style=\"width: 100%; padding: 10px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1);\"><span id=\"slice-spinner\" class=\"htmx-indicator\" style=\"display:none;\">⏳ </span> Slicing starten</button> <button type=\"button\" onclick=\"exportGCode()\" style=\"width: 100%; padding: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1);\">⬇ G-Code exportieren</button><script>\n\t\t\t\tfunction exportGCode() {\n\t\t\t\t\tfetch('/export', { method: 'POST' })\n\t\t\t\t\t\t.then(resp => {\n\t\t\t\t\t\t\tif (!resp.ok) throw new Error('Export failed');\n\t\t\t\t\t\t\treturn resp.blob();\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.then(blob => {\n\t\t\t\t\t\t\tconst url = URL.createObjectURL(blob);\n\t\t\t\t\t\t\tconst a = document.createElement('a');\n\t\t\t\t\t\t\ta.href = url;\n\t\t\t\t\t\t\ta.download = 'print.gcode';\n\t\t\t\t\t\t\tdocument.body.appendChild(a);\n\t\t\t\t\t\t\ta.click();\n\t\t\t\t\t\t\tdocument.body.removeChild(a);\n\t\t\t\t\t\t\tURL.revokeObjectURL(url);\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.catch(err => alert('Export fehlgeschlagen: ' + err.message));\n\t\t\t\t}\n\t\t\t</script></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if paths != nil {
templ_7745c5c3_Err = templ.JSFuncCall("setModelPath", marshal(paths)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSFuncCall("setSliceConfig", marshal(config)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// STLViewer renders the full page — used only on initial GET /
func STLViewer(bm *model.BaseModel, paths []model.ContinuousPath, config *model.SliceConfig) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var41 := templ.GetChildren(ctx)
if templ_7745c5c3_Var41 == nil {
templ_7745c5c3_Var41 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div id=\"viewer-root\"><script src=\"https://unpkg.com/htmx.org@1.9.10\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bm == nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"stl-viewer-container\" style=\"width: 100%; height: 100%; position: relative; display: flex; align-items: center; justify-content: center; background: #2e3842; border: 1px solid #444;\"><div style=\"text-align: center; color: #aaa;\"><p style=\"font-size: 18px; margin-bottom: 10px;\">Kein Modell geladen</p><p style=\"font-size: 14px;\">Bitte laden Sie eine Datei hoch.</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<div class=\"stl-viewer-container\" style=\"width: 100%; height: 100%; position: relative; background: #1d232a; overflow: hidden; display: flex;\"><canvas id=\"stl-canvas\" style=\"display: block; flex: 1; min-width: 0; cursor: grab;\"></canvas>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = SlicerSidebar(bm, paths, config).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.Raw(fmt.Sprintf("<script>%s</script>", viewerJs)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSFuncCall("setModelData", marshal(bm)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSFuncCall("setModelPath", marshal(paths)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSFuncCall("setSliceConfig", marshal(config)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate