diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3115dee..616c0fe 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -267,7 +267,7 @@ classDiagram
class HelixParameters {
double LayerHeight
bool DirectionCCW
- int FramesPerLayer
+ double FrameSpacingMm
bool SpiralSlice
bool OuterWallBracing
int BracingContactPoints
diff --git a/CCL_Clay3DP/Core/BaseBuilder.cs b/CCL_Clay3DP/Core/BaseBuilder.cs
index c1331ca..1f82175 100644
--- a/CCL_Clay3DP/Core/BaseBuilder.cs
+++ b/CCL_Clay3DP/Core/BaseBuilder.cs
@@ -99,8 +99,10 @@ public static class BaseBuilder
/// pitch as the part body.
/// Material bead diameter in mm.
/// Drives infill line spacing.
- /// Frame count per closed loop
- /// (skirt and per-layer contour). Mirrors HelixParameters.FramesPerLayer.
+ /// Target arc-length spacing between
+ /// frames on the skirt and on each base layer contour, in mm.
+ /// Mirrors HelixParameters.FrameSpacingMm so the base prints at
+ /// the same bead density as the part body (Issue #22).
/// BaseResult with frames and bake geometry. Returns
/// an empty result (LayerCount=0, empty lists) if the input
/// contour is unusable — caller treats that as "no base".
@@ -109,7 +111,7 @@ public static BaseResult Build(
BaseSettings settings,
double layerHeight,
double beadDiameter,
- int framesPerLayer)
+ double frameSpacingMm)
{
var result = new BaseResult { LayerHeight = layerHeight };
@@ -142,7 +144,7 @@ public static BaseResult Build(
if (result.SkirtCurve != null)
{
result.SkirtFrames = SkirtBuilder.SampleSkirtFrames(
- result.SkirtCurve, framesPerLayer);
+ result.SkirtCurve, frameSpacingMm);
}
for (int i = 0; i < n; i++)
@@ -153,7 +155,7 @@ public static BaseResult Build(
var contour = footprint.DuplicateCurve();
contour.Translate(0.0, 0.0, z);
result.ContourCurves.Add(contour);
- result.Frames.AddRange(SampleContourFrames(contour, framesPerLayer));
+ result.Frames.AddRange(SampleContourFrames(contour, frameSpacingMm));
// Infill: alternating ±45 per layer. Even layers (i=0,2,…)
// at +45°; odd layers at -45°. Stacked, this gives a
@@ -181,14 +183,20 @@ public static BaseResult Build(
///
/// Sample a closed planar contour into frames suitable for the
/// robot. Mirrors SkirtBuilder.SampleSkirtFrames: uniform arc
- /// length, last sample coincides with first to close the loop,
- /// frame YAxis = +Z so the build plate stays flat regardless
+ /// length at frameSpacingMm (sample count derived from perimeter
+ /// per Issue #22), last sample coincides with first to close the
+ /// loop, frame YAxis = +Z so the build plate stays flat regardless
/// of the SpiralFollowsCurveNormal setting.
///
- private static List SampleContourFrames(Curve contour, int sampleCount)
+ private static List SampleContourFrames(Curve contour, double frameSpacingMm)
{
var frames = new List();
- if (contour == null || sampleCount < 4) return frames;
+ if (contour == null || frameSpacingMm <= 0.0) return frames;
+
+ double perimeter = contour.GetLength();
+ if (perimeter <= 0.0) return frames;
+
+ int sampleCount = Math.Max(4, (int)Math.Ceiling(perimeter / frameSpacingMm));
double[] ts = contour.DivideByCount(sampleCount, true);
if (ts == null || ts.Length == 0) return frames;
diff --git a/CCL_Clay3DP/Core/SkirtBuilder.cs b/CCL_Clay3DP/Core/SkirtBuilder.cs
index c82fce8..167505e 100644
--- a/CCL_Clay3DP/Core/SkirtBuilder.cs
+++ b/CCL_Clay3DP/Core/SkirtBuilder.cs
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
using System.Collections.Generic;
using System.Linq;
using Rhino.Geometry;
@@ -92,19 +93,27 @@ private static Curve SafeOffset(Curve curve, Plane plane,
///
/// Sample the skirt curve into a list of frames the robot can
- /// follow. Sampling is by uniform arc-length so the per-frame
- /// spacing is consistent regardless of where the curve's
- /// internal seam lies. The last frame duplicates the first so
- /// the loop closes back on itself when RoboDK traces the curve.
+ /// follow. Sampling is by uniform arc-length at frameSpacingMm
+ /// (Issue #22): the sample count is derived from the skirt's
+ /// perimeter, so the bead density matches the spiral toolpath
+ /// regardless of part size. The last frame duplicates the first
+ /// so the loop closes back on itself when RoboDK traces the curve.
///
/// Frame normals are ALWAYS +Z world (build plate up). The
/// SpiralFollowsCurveNormal toggle does not apply to the skirt
/// — it sits flat on the plate by construction.
///
- public static List SampleSkirtFrames(Curve skirt, int sampleCount)
+ public static List SampleSkirtFrames(Curve skirt, double frameSpacingMm)
{
var frames = new List();
- if (skirt == null || sampleCount < 4) return frames;
+ if (skirt == null || frameSpacingMm <= 0.0) return frames;
+
+ double perimeter = skirt.GetLength();
+ if (perimeter <= 0.0) return frames;
+
+ // Sample count = perimeter / spacing, but never below 4 — a
+ // closed loop needs at least four corners to be meaningful.
+ int sampleCount = Math.Max(4, (int)Math.Ceiling(perimeter / frameSpacingMm));
// DivideByCount(N, true) returns N+1 parameters, with the last
// one at curve.Domain.T1 — for a closed curve that coincides
diff --git a/CCL_Clay3DP/Core/SpiralInterpolator.cs b/CCL_Clay3DP/Core/SpiralInterpolator.cs
index 0067d03..e2b7f32 100644
--- a/CCL_Clay3DP/Core/SpiralInterpolator.cs
+++ b/CCL_Clay3DP/Core/SpiralInterpolator.cs
@@ -18,103 +18,349 @@
namespace CCL_Clay3DP.Core
{
+ ///
+ /// Two-step spiral toolpath construction:
+ /// 1. Build one continuous spiral curve. Helical pitch = LayerHeight
+ /// (one revolution per LayerHeight of Z). Surface fidelity comes
+ /// from re-slicing the source brep/mesh at a fine pitch; angular
+ /// consistency across layers comes from aligning each contour's
+ /// seam to the +X-most point as seen from a shared centroid.
+ /// Chord-blend samples are laterally projected onto the source
+ /// surface where the surface is steep enough that the projection
+ /// stays within LayerHeight/2 in Z.
+ /// 2. Walk the spiral curve from bottom to top at the user's
+ /// FrameSpacingMm. The output points are uniformly spaced along
+ /// arc length and Z-monotonic.
+ ///
public static class SpiralInterpolator
{
///
- /// Generate a continuous spiral toolpath by interpolating between
- /// consecutive contour curves. The Z coordinate ramps linearly from
- /// one contour's height to the next, creating a seamless spiral
- /// with no Z-jumps (vase mode).
+ /// Density of intermediate spiral sampling relative to the output
+ /// frame spacing. Eight intermediate samples between every two
+ /// output frames keeps the polyline scaffold faithful to the
+ /// contour shape — at 1× the output sampling the scaffold would
+ /// cut corners on curvy contours and the resampled points would
+ /// inherit those shortcuts.
///
- /// Closed curves sorted bottom to top.
- /// Number of sample points per contour revolution.
- /// Start angle in degrees (0 = +X direction).
- /// True for counter-clockwise, false for clockwise.
- /// Ordered list of toolpath points forming a continuous spiral.
+ private const int IntermediateDensityFactor = 8;
+
+ ///
+ /// Floor on dense samples per contour pair regardless of perimeter.
+ /// Below this the polyline can't represent a turn smoothly even
+ /// on very small parts.
+ ///
+ private const int MinSamplesPerContourPair = 4;
+
+ ///
+ /// Default Z pitch for the internal fine-slicing pass. Independent
+ /// of the user's print layer height — fine slicing improves path
+ /// fidelity to the source surface without changing what the printer
+ /// deposits.
+ ///
+ public const double DefaultPathSlicePitchMm = 0.5;
+
+ ///
+ /// Distance, in mm, used to construct seam-alignment targets far
+ /// outside any plausible contour. Curve.ClosestPoint to a target
+ /// this far in seamDir resolves to the contour's extreme point in
+ /// that direction, which is what we want for the seam.
+ ///
+ private const double SeamRayDistanceMm = 10000.0;
+
+ ///
+ /// Spatial tolerance for de-duplicating coincident endpoints when
+ /// resampling closed curves — DivideByLength can return both T0
+ /// and T1 on closed curves, which collapse to the same XYZ.
+ ///
+ private const double PointDedupTolMm = 1e-3;
+
+ ///
+ /// Build a spiral toolpath whose helical pitch equals
+ /// (one full revolution per
+ /// LayerHeight of Z rise) and whose consecutive output points are
+ /// spaced by along the spiral's
+ /// arc length. When a brep or mesh is supplied, the interpolator
+ /// re-slices internally at
+ /// for surface fidelity.
+ ///
+ /// Closed curves sorted bottom to top.
+ /// Used as a Z-range source and as the working contour list
+ /// when brep/mesh re-slicing isn't possible.
+ /// Arc-length spacing between
+ /// consecutive output points. Must be > 0.
+ /// Helical pitch of the spiral: one
+ /// full turn per this much Z rise. Must be > 0.
+ /// Angle in degrees at which the
+ /// spiral starts on the bottom contour, measured from the +X
+ /// direction around the centroid (0 = +X).
+ /// True for counter-clockwise winding viewed
+ /// from +Z, false for clockwise.
+ /// Optional brep source for fine re-slicing.
+ /// Optional mesh source for fine re-slicing
+ /// (also fallback when brep slicing returns null).
+ /// Internal fine-slice Z pitch.
public static List Interpolate(
List contours,
- int pointsPerLayer,
- double startAngle,
+ double frameSpacingMm,
+ double layerHeightMm,
+ double startAngleDegrees,
bool ccw,
+ Brep brep = null,
+ Mesh mesh = null,
+ double pathSlicePitchMm = DefaultPathSlicePitchMm,
Action progress = null)
{
if (contours.Count < 2)
throw new Exception("Need at least 2 contours to interpolate");
+ if (frameSpacingMm <= 0.0)
+ throw new ArgumentException("frameSpacingMm must be > 0", nameof(frameSpacingMm));
+ if (layerHeightMm <= 0.0)
+ throw new ArgumentException("layerHeightMm must be > 0", nameof(layerHeightMm));
- // Ensure all contours have consistent direction
- AlignContourDirections(contours, ccw);
+ // Step A: build one continuous spiral curve.
- // Align seam points so the spiral doesn't twist randomly
- AlignSeamPoints(contours, startAngle);
+ // A1. Fine-slice the source so each contour sits on the actual
+ // surface. Falls back to the input contours when no source is
+ // available or fine slicing wouldn't be a refinement.
+ var workingContours = RefineContoursIfPossible(
+ contours, brep, mesh, pathSlicePitchMm);
- var spiralPoints = new List();
+ // A2. Align winding so walking parameter t forward = chosen
+ // winding direction (CCW/CW), and align seams via a SHARED
+ // centroid (mean of all per-layer centroids). With seams
+ // angularly aligned, parameter t=0 maps to the same angular
+ // column on every contour — arc-length sampling at the same
+ // t on adjacent contours yields chord blends that follow the
+ // actual perimeter, even for multi-lobe / wavy shapes where
+ // ray-cast-from-centroid would short-circuit across the part.
+ AlignContourDirections(workingContours, ccw);
+ Point3d sharedCentroid = ComputeSharedCentroid(workingContours);
+ AlignSeamPoints(workingContours, sharedCentroid, startAngleDegrees);
+
+ // A3. Sample the on-surface spiral. Each sample's parameter on
+ // the contour is t = ((z − zMin) / LayerHeight) mod 1 — one
+ // full revolution per LayerHeight of Z rise, regardless of
+ // how many fine slices we have. Where the source brep/mesh
+ // is available, each chord-blend point is laterally projected
+ // onto the surface (constrained to small Z deltas so the
+ // spiral stays monotonic in Z even on near-horizontal regions).
+ var dense = BuildSpiralSamples(workingContours,
+ layerHeightMm, frameSpacingMm, brep, mesh, progress);
+ if (dense.Count < 2) return dense;
+
+ // A4. Connect samples with a polyline. PolylineCurve (not a
+ // degree-3 fit) keeps the curve on the linear blend of our
+ // on-surface samples — a NURBS fit would smooth between them
+ // and overshoot the surface.
+ var spiralCurve = new PolylineCurve(new Polyline(dense));
+
+ // Step B: walk the spiral curve from bottom to top at the
+ // user's frame spacing.
+ return ResampleByArcLength(spiralCurve, frameSpacingMm);
+ }
- // Walk between consecutive contour pairs
- int totalLayers = contours.Count - 1;
- for (int layer = 0; layer < totalLayers; layer++)
+ ///
+ /// Visualization curve through the final toolpath points. Polyline
+ /// (not NURBS) so what the user sees matches what the robot receives.
+ ///
+ public static Curve CreateSpiralCurve(List points)
+ {
+ if (points == null || points.Count < 2) return null;
+ return new PolylineCurve(new Polyline(points));
+ }
+
+ ///
+ /// Re-slice the source brep/mesh at
+ /// between the Z range of . Always
+ /// returns a fresh list — even on the no-refinement paths it
+ /// copies the input, because the caller mutates the returned
+ /// list (winding flips, seam rotation) and must not corrupt the
+ /// caller's contour list.
+ ///
+ private static List RefineContoursIfPossible(
+ List contours, Brep brep, Mesh mesh, double pitchMm)
+ {
+ if (brep == null && mesh == null) return new List(contours);
+ if (pitchMm <= 0.0) return new List(contours);
+
+ double zMin = ContourZ(contours[0]);
+ double zMax = ContourZ(contours[contours.Count - 1]);
+ if (zMax - zMin <= pitchMm) return new List(contours);
+
+ double existingPitch = (zMax - zMin) / (contours.Count - 1);
+ if (pitchMm >= existingPitch) return new List(contours);
+
+ var refined = new List();
+ for (double z = zMin; z <= zMax + 1e-6; z += pitchMm)
{
- progress?.Invoke(0.03 + (double)layer / totalLayers * 0.02); // 3-5%
+ Curve sliced = null;
+ if (brep != null)
+ sliced = ContourSlicer.SliceBrepAt(brep, z);
+ if (sliced == null && mesh != null)
+ sliced = ContourSlicer.SliceMeshAt(mesh, z);
+ if (sliced != null)
+ refined.Add(sliced);
+ }
- var lower = contours[layer];
- var upper = contours[layer + 1];
+ return refined.Count >= 2 ? refined : new List(contours);
+ }
- double lowerZ = ContourZ(lower);
- double upperZ = ContourZ(upper);
+ ///
+ /// Generate dense on-surface samples along the spiral. For each
+ /// adjacent contour pair, emit K samples whose Z rises linearly
+ /// across the pair and whose contour parameter t advances such
+ /// that one full revolution corresponds to exactly LayerHeight
+ /// of Z rise (helical pitch). Within a pair, each sample is the
+ /// chord blend between the two bracketing contours, each
+ /// sampled at the same arc-length parameter t.
+ ///
+ /// Arc-length parameterization walks the actual perimeter — so
+ /// multi-lobe / non-convex contours are traced correctly. Seam
+ /// alignment (done earlier, in )
+ /// guarantees that the same t lands at the same angular column
+ /// on every contour, so chord blends stay close to the surface.
+ /// Chord deviation shrinks with contour spacing — fine slicing
+ /// keeps it small even on wavy organic meshes.
+ ///
+ private static List BuildSpiralSamples(
+ List contours,
+ double layerHeightMm, double frameSpacingMm,
+ Brep brep, Mesh mesh,
+ Action progress)
+ {
+ var dense = new List();
+ int totalPairs = contours.Count - 1;
+ if (totalPairs < 1) return dense;
- double lowerLength = lower.GetLength();
- double upperLength = upper.GetLength();
+ double zMin = ContourZ(contours[0]);
+ double zMax = ContourZ(contours[contours.Count - 1]);
- for (int i = 0; i < pointsPerLayer; i++)
+ // Density: each pair gets enough samples that adjacent dense
+ // points are ~frameSpacing / 8 apart along the longest
+ // perimeter. Scales with the angular range each pair covers,
+ // so a full-turn pair gets the full count and a 1/8-turn
+ // pair gets 1/8 the count.
+ double maxPerimeter = 0.0;
+ foreach (var c in contours)
+ {
+ double len = c.GetLength();
+ if (len > maxPerimeter) maxPerimeter = len;
+ }
+ double avgPairPitch = (zMax - zMin) / totalPairs;
+ double turnFractionPerPair = avgPairPitch / layerHeightMm;
+ double targetSpacingMm = frameSpacingMm / IntermediateDensityFactor;
+ int samplesPerPair = Math.Max(MinSamplesPerContourPair,
+ (int)Math.Ceiling(maxPerimeter * turnFractionPerPair / targetSpacingMm));
+
+ // Constrained projection threshold: how far the closest surface
+ // point's Z can differ from the chord-blend Z before we reject
+ // the projection. Half the user's LayerHeight is the safe band
+ // — closer than that and the surface is steep enough to project
+ // onto without crossing into a different layer; further than
+ // that and the projection is on a near-horizontal section that
+ // would yank the point onto a different layer entirely.
+ double projectionZTolerance = layerHeightMm * 0.5;
+
+ for (int pair = 0; pair < totalPairs; pair++)
+ {
+ progress?.Invoke(0.03 + (double)pair / totalPairs * 0.02);
+
+ var lower = contours[pair];
+ var upper = contours[pair + 1];
+ double zLo = ContourZ(lower);
+ double zHi = ContourZ(upper);
+
+ for (int k = 0; k < samplesPerPair; k++)
{
- // Normalized parameter along the contour [0, 1)
- double t = (double)i / pointsPerLayer;
+ double zFrac = (double)k / samplesPerPair;
+ double z = zLo + (zHi - zLo) * zFrac;
+
+ double turns = (z - zMin) / layerHeightMm;
+ double t = turns - Math.Floor(turns);
- // Fraction through this layer for Z interpolation
- double zFrac = t;
- double z = lowerZ + (upperZ - lowerZ) * zFrac;
+ Point3d ptLo = SampleAtArcLengthParameter(lower, t);
+ Point3d ptHi = SampleAtArcLengthParameter(upper, t);
- // Sample both contours at the same normalized parameter
- Point3d ptLower = SampleAtNormalized(lower, t);
- Point3d ptUpper = SampleAtNormalized(upper, t);
+ double x = ptLo.X + (ptHi.X - ptLo.X) * zFrac;
+ double y = ptLo.Y + (ptHi.Y - ptLo.Y) * zFrac;
+ var pt = new Point3d(x, y, z);
- // Interpolate XY position between lower and upper contour
- double x = ptLower.X + (ptUpper.X - ptLower.X) * zFrac;
- double y = ptLower.Y + (ptUpper.Y - ptLower.Y) * zFrac;
+ // Constrained lateral projection: where the surface is
+ // steep, the closest surface point is at nearly the same
+ // Z as our chord — adopt its XY so the bead hugs the
+ // actual wall. Where the surface is near-horizontal,
+ // the closest point jumps to a different layer; reject
+ // the projection and keep the chord-blend (the price of
+ // staying on a printable monotonic-Z spiral).
+ pt = LaterallyProjectIfSafe(pt, brep, mesh, projectionZTolerance);
- spiralPoints.Add(new Point3d(x, y, z));
+ dense.Add(pt);
}
}
- // Add the final point at the top of the last contour
- var lastContour = contours[contours.Count - 1];
- spiralPoints.Add(SampleAtNormalized(lastContour, 0.0));
+ // Finish on the top contour at the spiral's terminal parameter
+ // so the curve ends on the actual surface, not mid-blend.
+ var top = contours[contours.Count - 1];
+ double turnsTop = (zMax - zMin) / layerHeightMm;
+ double tTop = turnsTop - Math.Floor(turnsTop);
+ dense.Add(SampleAtArcLengthParameter(top, tTop));
- return spiralPoints;
+ return dense;
}
///
- /// Create an interpolated curve through the spiral points.
+ /// If the closest point on the brep/mesh has a Z within
+ /// of , return a
+ /// new point with that surface point's XY and the chord's Z. Otherwise
+ /// return unchanged. Z is always preserved so
+ /// the spiral stays monotonic in Z; the projection only slides the
+ /// point laterally onto the surface where doing so is safe.
///
- public static Curve CreateSpiralCurve(List points, int degree = 3)
+ private static Point3d LaterallyProjectIfSafe(Point3d chord, Brep brep,
+ Mesh mesh, double zTolerance)
{
- if (points.Count < 2)
- return null;
+ if (brep == null && mesh == null) return chord;
+
+ Point3d surface = Point3d.Unset;
+ if (brep != null)
+ {
+ var cp = brep.ClosestPoint(chord);
+ if (cp.IsValid) surface = cp;
+ }
+ if (!surface.IsValid && mesh != null)
+ {
+ var mp = mesh.ClosestMeshPoint(chord, 0.0);
+ if (mp != null && mp.Point.IsValid) surface = mp.Point;
+ }
+ if (!surface.IsValid) return chord;
- var curve = Curve.CreateInterpolatedCurve(points, degree);
- return curve;
+ if (Math.Abs(surface.Z - chord.Z) > zTolerance) return chord;
+ return new Point3d(surface.X, surface.Y, chord.Z);
}
///
- /// Ensure all contours wind in the same direction.
+ /// Sample a curve at a normalized arc-length parameter t ∈ [0, 1).
+ /// Falls back to the curve's domain mapping if Rhino can't resolve
+ /// the arc-length lookup (degenerate curves, mostly).
+ ///
+ private static Point3d SampleAtArcLengthParameter(Curve curve, double t)
+ {
+ double len = curve.GetLength();
+ if (len > 0.0 && curve.LengthParameter(t * len, out double param))
+ return curve.PointAt(param);
+ return curve.PointAt(curve.Domain.ParameterAt(t));
+ }
+
+ ///
+ /// Reverse contours whose closed-curve orientation doesn't match
+ /// the requested winding, so walking parameter t forward always
+ /// traces in the chosen direction (CCW or CW).
///
private static void AlignContourDirections(List contours, bool ccw)
{
foreach (var contour in contours)
{
- // CurveOrientation returns CounterClockwise or Clockwise
- // when viewed from above (looking down -Z)
var orientation = contour.ClosedCurveOrientation(Vector3d.ZAxis);
-
bool isCCW = orientation == CurveOrientation.CounterClockwise;
if (isCCW != ccw)
contour.Reverse();
@@ -122,59 +368,99 @@ private static void AlignContourDirections(List contours, bool ccw)
}
///
- /// Align seam points across contours so the spiral starts at a
- /// consistent angular position. Uses the start angle to define
- /// the seam direction from each contour's centroid.
+ /// Rotate each closed contour's parameterization so t=0 lands at
+ /// the +X-most point of the contour as seen from
+ /// , with
+ /// rotating that reference direction. Using the SHARED centroid
+ /// (not each contour's own centroid) keeps seams angularly
+ /// aligned across layers even when individual contour centroids
+ /// shift with the surface.
///
- private static void AlignSeamPoints(List contours, double startAngleDegrees)
+ private static void AlignSeamPoints(List contours,
+ Point3d sharedCentroid, double startAngleDegrees)
{
double angleRad = startAngleDegrees * Math.PI / 180.0;
var seamDir = new Vector3d(Math.Cos(angleRad), Math.Sin(angleRad), 0);
foreach (var contour in contours)
{
- // Find the centroid of the contour
- var areaProps = AreaMassProperties.Compute(contour);
- if (areaProps == null) continue;
-
- Point3d centroid = areaProps.Centroid;
-
- // Project a point outward from centroid in the seam direction
- Point3d seamTarget = centroid + seamDir * 10000;
- seamTarget = new Point3d(seamTarget.X, seamTarget.Y, centroid.Z);
-
- // Find closest point on contour to the seam target direction
+ Point3d seamTarget = sharedCentroid + seamDir * SeamRayDistanceMm;
+ seamTarget = new Point3d(seamTarget.X, seamTarget.Y, contour.PointAtStart.Z);
if (contour.ClosestPoint(seamTarget, out double t))
contour.ChangeClosedCurveSeam(t);
}
}
///
- /// Sample a closed curve at a normalized parameter [0, 1).
- /// Uses arc-length parameterization for even spacing.
+ /// Mean of all contours' area-centroids, projected to z=0. Used
+ /// as the shared anchor for seam alignment across layers. Falls
+ /// back to each contour's bbox center when AreaMassProperties
+ /// returns null. Returns Point3d.Origin when no contour has a
+ /// usable centroid or bbox (degenerate input).
+ ///
+ /// Public so the panel can reuse it for layer-mode bracing —
+ /// every consumer that wants to align angular positions across
+ /// a contour stack should anchor to the same shared centroid.
///
- private static Point3d SampleAtNormalized(Curve curve, double normalizedT)
+ public static Point3d ComputeSharedCentroid(List contours)
{
- double length = curve.GetLength();
- double targetLength = normalizedT * length;
-
- if (!curve.LengthParameter(targetLength, out double param))
+ double sumX = 0.0, sumY = 0.0;
+ int n = 0;
+ foreach (var c in contours)
{
- // Fallback: use domain-based parameter
- double domainT = curve.Domain.ParameterAt(normalizedT);
- return curve.PointAt(domainT);
+ if (c == null) continue;
+ Point3d centre;
+ var amp = AreaMassProperties.Compute(c);
+ if (amp != null)
+ {
+ centre = amp.Centroid;
+ }
+ else
+ {
+ var bb = c.GetBoundingBox(true);
+ if (!bb.IsValid) continue;
+ centre = bb.Center;
+ }
+ sumX += centre.X;
+ sumY += centre.Y;
+ n++;
}
-
- return curve.PointAt(param);
+ return n > 0 ? new Point3d(sumX / n, sumY / n, 0.0) : Point3d.Origin;
}
///
- /// Get the Z height of a contour (average Z of control points / sample points).
+ /// Walk a curve from start to end at uniform arc-length intervals.
+ /// Always appends the curve end as the last point so the toolpath
+ /// terminates at the top of the part rather than at the last
+ /// full-spacing tick.
///
- private static double ContourZ(Curve contour)
+ private static List ResampleByArcLength(Curve curve, double spacingMm)
{
- // For a planar horizontal contour, any point's Z is the height
- return contour.PointAtStart.Z;
+ var pts = new List();
+ double total = curve.GetLength();
+ if (total <= 0.0) return pts;
+
+ if (total <= spacingMm)
+ {
+ pts.Add(curve.PointAtStart);
+ pts.Add(curve.PointAtEnd);
+ return pts;
+ }
+
+ double[] ts = curve.DivideByLength(spacingMm, true);
+ if (ts != null)
+ {
+ foreach (var t in ts)
+ pts.Add(curve.PointAt(t));
+ }
+
+ Point3d end = curve.PointAtEnd;
+ if (pts.Count == 0 || pts[pts.Count - 1].DistanceTo(end) > PointDedupTolMm)
+ pts.Add(end);
+
+ return pts;
}
+
+ private static double ContourZ(Curve contour) => contour.PointAtStart.Z;
}
}
diff --git a/CCL_Clay3DP/Models/Parameters.cs b/CCL_Clay3DP/Models/Parameters.cs
index 72ed555..64c6595 100644
--- a/CCL_Clay3DP/Models/Parameters.cs
+++ b/CCL_Clay3DP/Models/Parameters.cs
@@ -106,12 +106,20 @@ public class HelixParameters
public double LayerHeight { get; set; } = 4.0;
// StartAngle is no longer exposed in the SettingsDialog (Issue #16
// — was struck through as unused) but the spiral interpolator still
- // reads it, so the field stays here. Default 0 means "start the
- // spiral at the contour's natural curve start"; that's been the
- // de-facto behavior since the dialog never had a useful preset.
+ // reads it, so the field stays here. Degrees, measured from +X
+ // around each contour's centroid: 0 = +X, 90 = +Y. Sets where the
+ // spiral starts on the bottom contour.
public double StartAngle { get; set; } = 0.0;
public bool DirectionCCW { get; set; } = true;
- public int FramesPerLayer { get; set; } = 360;
+
+ // Target arc-length spacing between consecutive toolpath frames, in
+ // millimetres (Issue #22). Replaces the legacy FramesPerLayer count:
+ // the old normalized-parameter sampling produced uneven physical
+ // spacing whenever contour perimeters varied within or between
+ // layers. Sampling by mm gives uniform extrusion regardless of
+ // part shape. Applied to spiral toolpath, skirt loop, and base
+ // layer contours so every closed loop has consistent bead density.
+ public double FrameSpacingMm { get; set; } = 10.0;
// Toolpath mode. When true, the Slice button produces a continuous
// spiral (original behavior). When false, it produces discrete
@@ -129,8 +137,8 @@ public class HelixParameters
// number of "kisses" the bracing makes with the wall, countable
// by eye in the viewport. The generator samples 2× this many
// points internally (alternating outer/inner) so each touch pairs
- // with one inner anchor. Decoupled from FramesPerLayer (Issue #11);
- // range 4..500 enforced at the UI.
+ // with one inner anchor. Decoupled from the spiral frame spacing
+ // (Issue #11); range 4..500 enforced at the UI.
public int BracingContactPoints { get; set; } = 60;
// Bracing pattern selector (Issue #11 slice C). When false the
diff --git a/CCL_Clay3DP/Settings/SettingsDialog.cs b/CCL_Clay3DP/Settings/SettingsDialog.cs
index 38d83f4..44ce4f3 100644
--- a/CCL_Clay3DP/Settings/SettingsDialog.cs
+++ b/CCL_Clay3DP/Settings/SettingsDialog.cs
@@ -54,7 +54,7 @@ public class SettingsDialog : Dialog
private CheckBox _spiralFollowsCurveNormalCheck;
private NumericStepper _layerHeight;
private DropDown _directionDropDown;
- private NumericStepper _framesPerLayer;
+ private NumericStepper _frameSpacingMm;
// Robot fields. Tilt mode / LeadAngle / VerticalBias were dead
// config (no pipeline ever read them) — removed from both the
@@ -224,7 +224,7 @@ private void BuildUI()
_directionDropDown = new DropDown();
_directionDropDown.Items.Add("CCW");
_directionDropDown.Items.Add("CW");
- _framesPerLayer = CreateStepper(36, 3600, 36, 0);
+ _frameSpacingMm = CreateStepper(0.1, 50.0, 0.5, 1);
var spiralGroup = new GroupBox
{
@@ -246,8 +246,8 @@ private void BuildUI()
"Number of times the bracing toolpath touches the outer " +
"wall around each layer — i.e. the number of \"kisses\" " +
"you can count by eye in the viewport. Decoupled from " +
- "Frames per layer so bracing density is independent of " +
- "toolpath sampling."),
+ "the spiral Frame spacing so bracing density is " +
+ "independent of toolpath sampling."),
new TableRow(null, _sinusoidalBracingCheck),
new TableRow(null, _spiralFollowsCurveNormalCheck),
LabeledRow("Layer height (mm)", _layerHeight,
@@ -255,10 +255,12 @@ private void BuildUI()
"0.5x-1.0x the bead diameter."),
LabeledRow("Direction", _directionDropDown,
"CCW: counter-clockwise spiral / contour winding. CW: clockwise."),
- LabeledRow("Frames per layer", _framesPerLayer,
- "Number of robot frames sampled per closed loop. Higher = " +
- "smoother motion at the cost of larger G-code files and " +
- "longer slice time. KUKA CNC has no per-program frame limit."),
+ LabeledRow("Frame spacing (mm)", _frameSpacingMm,
+ "Arc-length distance between consecutive robot frames " +
+ "along the spiral, skirt, and base-layer contours. " +
+ "Smaller = smoother motion at the cost of larger G-code " +
+ "files and longer slice time. Applied uniformly so bead " +
+ "deposition stays even regardless of part perimeter."),
},
},
};
@@ -568,7 +570,13 @@ private void LoadValues()
_suppressSpiralNormalWarning = false;
_layerHeight.Value = _settings.Helix.LayerHeight;
_directionDropDown.SelectedIndex = _settings.Helix.DirectionCCW ? 0 : 1;
- _framesPerLayer.Value = _settings.Helix.FramesPerLayer;
+ // Clamp imported value to [0.1, 50] so an out-of-spec settings.json
+ // (or a legacy file with no FrameSpacingMm at all — the default
+ // 10.0 sits inside the range) doesn't trip the stepper.
+ double clampedSpacing = _settings.Helix.FrameSpacingMm;
+ if (clampedSpacing < 0.1) clampedSpacing = 0.1;
+ if (clampedSpacing > 50.0) clampedSpacing = 50.0;
+ _frameSpacingMm.Value = clampedSpacing;
// Clamp imported value to [4,500] so an out-of-spec settings.json
// doesn't trip the stepper. No parity constraint — the generator
// doubles this internally, guaranteeing an even sample count.
@@ -630,7 +638,7 @@ private void ApplyDialogToSettings()
_settings.Helix.SpiralFollowsCurveNormal = _spiralFollowsCurveNormalCheck.Checked ?? false;
_settings.Helix.LayerHeight = _layerHeight.Value;
_settings.Helix.DirectionCCW = _directionDropDown.SelectedIndex == 0;
- _settings.Helix.FramesPerLayer = (int)_framesPerLayer.Value;
+ _settings.Helix.FrameSpacingMm = _frameSpacingMm.Value;
_settings.Helix.BracingContactPoints = (int)_bracingContactPoints.Value;
_settings.Helix.SinusoidalBracing = _sinusoidalBracingCheck.Checked ?? false;
diff --git a/CCL_Clay3DP/Settings/SettingsManager.cs b/CCL_Clay3DP/Settings/SettingsManager.cs
index e01cbae..9aacb60 100644
--- a/CCL_Clay3DP/Settings/SettingsManager.cs
+++ b/CCL_Clay3DP/Settings/SettingsManager.cs
@@ -44,6 +44,14 @@ public static PipelineSettings Load()
// Migrate legacy settings on first load after rename
MigrateLegacySettingsIfNeeded();
+ // Note on field renames: Newtonsoft drops unknown JSON keys
+ // silently on deserialize, so legacy fields removed from the
+ // model (e.g. HelixParameters.FramesPerLayer in Issue #22)
+ // just disappear and the new field gets its default. We do
+ // not try to convert old counts to FrameSpacingMm because the
+ // mapping depends on per-part perimeter — defaulting is the
+ // honest behavior.
+
if (!File.Exists(ConfigFile))
return new PipelineSettings();
diff --git a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs
index 9681986..deacbdb 100644
--- a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs
+++ b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs
@@ -190,6 +190,7 @@ private void BuildUI()
{
"3DP::Contours",
"3DP::Spiral Toolpath",
+ "3DP::Spiral Points",
"3DP::Heatmap",
"3DP::Outer Toolpath",
"3DP::Bracing Outer Points",
@@ -653,13 +654,19 @@ private void RunSliceAndBake(GeometrySelection selection)
SetStatus($"Found {contours.Count} contours. Interpolating spiral...");
- // 3) Generate spiral toolpath
+ // 3) Generate spiral toolpath. Brep + mesh are passed so
+ // the interpolator can re-slice internally for surface
+ // fidelity, independent of the user's print layer height
+ // (which is the spiral's helical pitch).
var spiralPoints = SpiralInterpolator.Interpolate(
contours,
- _settings.Helix.FramesPerLayer,
+ _settings.Helix.FrameSpacingMm,
+ _settings.Helix.LayerHeight,
_settings.Helix.StartAngle,
_settings.Helix.DirectionCCW,
- reportProgress);
+ workingBrep,
+ workingMesh,
+ progress: reportProgress);
reportProgress(0.1);
SetStatus($"Computing {spiralPoints.Count} frames...");
@@ -722,7 +729,7 @@ private void RunSliceAndBake(GeometrySelection selection)
BakeSkirt(RhinoDoc.ActiveDoc, skirt);
_lastResult.SkirtCurve = skirt;
_lastResult.SkirtFrames = SkirtBuilder.SampleSkirtFrames(
- skirt, _settings.Helix.FramesPerLayer);
+ skirt, _settings.Helix.FrameSpacingMm);
}
Rhino.UI.StatusBar.UpdateProgressMeter(100, true);
@@ -761,6 +768,25 @@ private void BakeResults(SpiralResult result)
doc.Objects.AddCurve(result.SpiralCurve, attrs);
}
+ // Bake the raw toolpath points on a dedicated layer so the
+ // user can visually verify frame spacing and on-surface
+ // placement. Each ToolpathPoint becomes one point object —
+ // the gap between consecutive points equals FrameSpacingMm.
+ if (result.ToolpathPoints != null && result.ToolpathPoints.Count > 0)
+ {
+ int pointsLayer = EnsureLayer(doc, "3DP::Spiral Points",
+ System.Drawing.Color.Yellow);
+ ClearLayerObjects(doc, "3DP::Spiral Points");
+
+ var pointAttrs = new ObjectAttributes
+ {
+ LayerIndex = pointsLayer,
+ Name = "SpiralPoint",
+ };
+ foreach (var pt in result.ToolpathPoints)
+ doc.Objects.AddPoint(pt, pointAttrs);
+ }
+
doc.Views.Redraw();
}
@@ -1094,7 +1120,7 @@ private static void BakeBaseLayers(RhinoDoc doc, BaseResult baseResult)
_settings.Base,
_settings.Helix.LayerHeight,
_settings.Clay.BeadDiameter,
- _settings.Helix.FramesPerLayer);
+ _settings.Helix.FrameSpacingMm);
if (baseResult.LayerCount == 0)
return (selection.Brep, selection.Mesh, null, 0.0);
@@ -1413,14 +1439,15 @@ private void RunLayerSlice(GeometrySelection selection)
{
skirtNoBrace = SkirtBuilder.BuildSkirt(contours[0]);
skirtNoBraceFrames = SkirtBuilder.SampleSkirtFrames(
- skirtNoBrace, _settings.Helix.FramesPerLayer);
+ skirtNoBrace, _settings.Helix.FrameSpacingMm);
}
BakeSkirt(RhinoDoc.ActiveDoc, skirtNoBrace);
// Cache for Analyze: sample outer contours + build +Z-up
// frames so Clay / Robot / Both all work in this mode.
var pts = new List();
- foreach (var c in contours) AddCurvePoints(c, pts);
+ foreach (var c in contours)
+ AddCurvePoints(c, pts, _settings.Helix.FrameSpacingMm);
_lastGeometry = selection;
_lastResult = new SpiralResult
{
@@ -1442,9 +1469,9 @@ private void RunLayerSlice(GeometrySelection selection)
// Outer Wall Bracing: preview inward arrows so the user can
// flip the side before picking the offset distance. Contact-
- // point count is decoupled from FramesPerLayer (Issue #11)
- // so bracing density can be tuned independently of toolpath
- // sampling. BracingContactPoints means "number of times the
+ // point count is decoupled from the spiral frame spacing
+ // (Issue #11) so bracing density can be tuned independently
+ // of toolpath sampling. BracingContactPoints means "number of times the
// bracing touches the wall" — we double it for the generator
// so each pair (outer-touch, inner-anchor) accounts for one
// wall contact, matching what the user counts by eye.
@@ -1512,13 +1539,20 @@ private void RunLayerSlice(GeometrySelection selection)
// zigzag generator takes 2× (alternating outer/inner).
bool sinusoidal = _settings.Helix.SinusoidalBracing;
int contactPoints = _settings.Helix.BracingContactPoints;
+ // Shared reference center for sinusoidal phase: the mean
+ // of all contour centroids. Anchoring peaks to absolute
+ // angles around this point — instead of per-layer arc-
+ // length — makes contact points stack vertically across
+ // layers even when contour shape varies.
+ Point3d? sharedCenter = SpiralInterpolator.ComputeSharedCentroid(contours);
for (int i = 0; i < contours.Count; i++)
{
try
{
var r = sinusoidal
? Zigzag.ZigzagGenerator.BuildSinusoidalSingleContour(
- contours[i], contactPoints, distance, flipInward, wallOffset)
+ contours[i], contactPoints, distance, flipInward,
+ wallOffset, sharedCenter)
: Zigzag.ZigzagGenerator.BuildSingleContour(
contours[i], numPoints, distance, flipInward, wallOffset);
results.Add(r);
@@ -1552,9 +1586,9 @@ private void RunLayerSlice(GeometrySelection selection)
for (int i = 0; i < goodContours.Count; i++)
{
if (goodContours[i] != null)
- AddCurvePoints(goodContours[i], layerPts);
+ AddCurvePoints(goodContours[i], layerPts, _settings.Helix.FrameSpacingMm);
if (results[i].Zigzag != null)
- AddCurvePoints(results[i].Zigzag, layerPts);
+ AddCurvePoints(results[i].Zigzag, layerPts, _settings.Helix.FrameSpacingMm);
}
_lastGeometry = selection;
@@ -1587,7 +1621,7 @@ private void RunLayerSlice(GeometrySelection selection)
BakeSkirt(RhinoDoc.ActiveDoc, skirtBraced);
_lastResult.SkirtCurve = skirtBraced;
_lastResult.SkirtFrames = SkirtBuilder.SampleSkirtFrames(
- skirtBraced, _settings.Helix.FramesPerLayer);
+ skirtBraced, _settings.Helix.FrameSpacingMm);
}
SetStatus(WithTranslationNote(
@@ -1629,25 +1663,48 @@ private void BakeLayerContours(List contours)
}
///
- /// Pull points from a curve for Layer-mode Analyze caching. Polylines
- /// contribute their vertices directly; smooth curves are sampled at
- /// ~2 mm spacing. Duplicate closing vertex on closed polylines is
- /// skipped so frame tangents at the seam don't degenerate.
+ /// Walk a Layer-mode toolpath curve and emit points at uniform
+ /// arc-length intervals (Issue #22).
+ /// Applies to slice contours, bracing zigzag, and any other
+ /// per-layer curve — every closed loop prints at the same bead
+ /// density as the spiral mode. For closed curves the seam tick
+ /// is de-duplicated so frame tangents don't degenerate where the
+ /// polyline returns to its start.
///
- private static void AddCurvePoints(Curve c, List acc)
+ private static void AddCurvePoints(Curve c, List acc, double spacingMm)
{
- if (c == null) return;
- if (c.TryGetPolyline(out Polyline pl) && pl.Count > 0)
+ if (c == null || spacingMm <= 0.0) return;
+ double len = c.GetLength();
+ if (len <= 0.0) return;
+
+ var local = new List();
+ if (len <= spacingMm)
{
- int count = pl.IsClosed ? pl.Count - 1 : pl.Count;
- for (int i = 0; i < count; i++) acc.Add(pl[i]);
- return;
+ local.Add(c.PointAtStart);
+ if (!c.IsClosed) local.Add(c.PointAtEnd);
}
- double len = c.GetLength();
- int n = Math.Max(32, (int)(len / 2.0));
- var ts = c.DivideByCount(n, false);
- if (ts == null) return;
- foreach (var t in ts) acc.Add(c.PointAt(t));
+ else
+ {
+ double[] ts = c.DivideByLength(spacingMm, true);
+ if (ts != null)
+ foreach (var t in ts) local.Add(c.PointAt(t));
+
+ if (!c.IsClosed)
+ {
+ Point3d end = c.PointAtEnd;
+ if (local.Count == 0 || local[local.Count - 1].DistanceTo(end) > 1e-3)
+ local.Add(end);
+ }
+ else if (local.Count >= 2 &&
+ local[0].DistanceTo(local[local.Count - 1]) < 1e-3)
+ {
+ // Some Rhino builds include both T0 and T1 for closed
+ // curves; they coincide spatially.
+ local.RemoveAt(local.Count - 1);
+ }
+ }
+
+ acc.AddRange(local);
}
///
@@ -2048,6 +2105,10 @@ private void BakePreviewArrows(
ObjectDecoration = ObjectDecoration.EndArrowhead,
};
+ // Same shared center the toolpath generation will use, so the
+ // preview arrows land where the real contact points will be.
+ Point3d? sharedCenter = SpiralInterpolator.ComputeSharedCentroid(contours);
+
foreach (var contour in contours)
{
if (contour == null) continue;
@@ -2055,7 +2116,7 @@ private void BakePreviewArrows(
{
var r = sinusoidal
? Zigzag.ZigzagGenerator.BuildSinusoidalSingleContour(
- contour, contactPoints, length, flip, wallOffset)
+ contour, contactPoints, length, flip, wallOffset, sharedCenter)
: Zigzag.ZigzagGenerator.BuildSingleContour(
contour, numPoints, length, flip, wallOffset);
int n = Math.Min(r.OuterPoints.Count, r.InnerPoints.Count);
diff --git a/CCL_Clay3DP/Zigzag/ZigzagGenerator.cs b/CCL_Clay3DP/Zigzag/ZigzagGenerator.cs
index 4132f01..fb039e7 100644
--- a/CCL_Clay3DP/Zigzag/ZigzagGenerator.cs
+++ b/CCL_Clay3DP/Zigzag/ZigzagGenerator.cs
@@ -41,6 +41,11 @@ public static class ZigzagGenerator
// Tolerance for "start==end" detection on curves that aren't flagged closed
private const double CloseEndpointTol = 0.001;
+ // Far distance used to construct seam-alignment targets. The seam
+ // is then the contour's extreme point in the seamDir, via
+ // Curve.ClosestPoint to a target this far away.
+ private const double SeamRayDistanceMm = 10000.0;
+
public static SimpleZigzagResult BuildSingleContour(
Curve contour, int numPoints, double inwardDistance,
bool flipInward = false, double wallOffset = 0.0)
@@ -74,7 +79,7 @@ public static SimpleZigzagResult BuildSingleContour(
if (areaProps != null)
{
var c = areaProps.Centroid;
- var seamTarget = new Point3d(c.X + 10000, c.Y, c.Z);
+ var seamTarget = new Point3d(c.X + SeamRayDistanceMm, c.Y, c.Z);
if (contour.ClosestPoint(seamTarget, out double seamT))
contour.ChangeClosedCurveSeam(seamT);
}
@@ -172,16 +177,24 @@ public static SimpleZigzagResult BuildSingleContour(
/// Peaks of the cosine (cos=+1 → offset = wallOffset) are the wall
/// kisses; troughs (cos=−1 → offset = wallOffset + A) are the inner
/// anchors. One period per contact point, so N wall touches around
- /// the contour. Internally sampled at
- /// points per period so the robot toolpath reads as a smooth wave
- /// (no corner reversals → gentler on accel/decel).
+ /// the contour at evenly-spaced arc-length positions.
+ ///
+ /// sets the seam alignment target:
+ /// each layer's contour is rotated so its seam (curve parameter 0)
+ /// lands at the +X-most point of the contour as seen from the
+ /// shared centre. With identical seam direction across layers,
+ /// arc-length-aligned peaks land at the same angular column on
+ /// every layer (modulo per-contour perimeter differences). When
+ /// the parameter is null, the per-contour area centroid is used —
+ /// the legacy behaviour, which let seams drift if centroids shifted.
///
/// OuterPoints returned = the N peaks (= wall touches).
/// InnerPoints returned = the N troughs (= inner anchors).
///
public static SimpleZigzagResult BuildSinusoidalSingleContour(
Curve contour, int contactPoints, double inwardDistance,
- bool flipInward = false, double wallOffset = 0.0)
+ bool flipInward = false, double wallOffset = 0.0,
+ Point3d? sharedCenter = null)
{
if (contour == null) throw new Exception("Contour is null");
if (inwardDistance <= 0) throw new Exception("Inward distance must be positive");
@@ -197,22 +210,30 @@ public static SimpleZigzagResult BuildSinusoidalSingleContour(
isClosed = false;
}
- // Mirror BuildSingleContour's seam handling so the wave peaks
- // stack across layers instead of drifting around the contour.
+ // Seam alignment so peaks stack across layers (Issue #22 follow-up).
+ // The seam target is +X-far from sharedCenter when provided, or
+ // from the per-contour centroid as a fallback. Using a shared
+ // centre stops the seam drifting layer-to-layer when each
+ // contour's centroid wobbles in XY.
bool isCCW = true;
if (isClosed)
{
var orient = contour.ClosedCurveOrientation(Vector3d.ZAxis);
isCCW = orient != CurveOrientation.Clockwise;
- var areaProps = AreaMassProperties.Compute(contour);
- if (areaProps != null)
+ Point3d seamRef;
+ if (sharedCenter.HasValue)
{
- var c = areaProps.Centroid;
- var seamTarget = new Point3d(c.X + 10000, c.Y, c.Z);
- if (contour.ClosestPoint(seamTarget, out double seamT))
- contour.ChangeClosedCurveSeam(seamT);
+ seamRef = sharedCenter.Value;
+ }
+ else
+ {
+ var amp = AreaMassProperties.Compute(contour);
+ seamRef = amp?.Centroid ?? contour.PointAtStart;
}
+ var seamTarget = new Point3d(seamRef.X + SeamRayDistanceMm, seamRef.Y, seamRef.Z);
+ if (contour.ClosestPoint(seamTarget, out double seamT))
+ contour.ChangeClosedCurveSeam(seamT);
}
int N = contactPoints;
@@ -256,19 +277,14 @@ public static SimpleZigzagResult BuildSinusoidalSingleContour(
var pt = p + inwardDir * offset;
samples.Add(pt);
- // Pull out the peaks / troughs by phase position so the
- // OuterPoints / InnerPoints visualizations show N each,
- // independent of K.
if (j % K == 0) peaks.Add(pt);
else if (K % 2 == 0 && j % K == K / 2) troughs.Add(pt);
}
- // Closure dup — same convention as the zigzag generator so the
- // robot returns to its starting kiss after each layer.
+ // Closure dup — robot returns to its starting kiss after each layer.
if (isClosed) samples.Add(samples[0]);
- // Inner curve through the troughs only (analog to the zigzag's
- // innerLoop). Not printed; useful as a debug overlay.
+ // Inner curve through the troughs only — visualization overlay.
var innerLoop = new List(troughs);
if (isClosed && innerLoop.Count > 0) innerLoop.Add(innerLoop[0]);
diff --git a/README.md b/README.md
index a7dec0d..b126f1f 100644
--- a/README.md
+++ b/README.md
@@ -303,9 +303,10 @@ Rhino via `_PlugInManager → Install…`.
custom bead diameter, max overhang, bond ratio, density.
- **Toolpath**: Spiral Slice (vase mode) or Layer Slice; Outer Wall
Bracing (Layer mode only, ruled-geometry only); Bracing contact
- points (4–500, independent of Frames per layer); Sinusoidal bracing
+ points (4–500, independent of Frame spacing); Sinusoidal bracing
(smooth cosine path vs. zigzag); Spiral follows curve normal (Spiral
- mode only); layer height; frames per layer; CCW/CW direction.
+ mode only); layer height; frame spacing in mm (uniform arc-length
+ between toolpath frames — Issue #22); CCW/CW direction.
- **Robot / printer**: feed rate (mm/s), travel speed, spindle S
value, nozzle tool (T10/T11/T12), RoboDK paths.
- **Build Volume (mm)**: X min/max, Y min/max, Z height of the cell