From 97d79501a579e8c160294d5f823c6f98b060f88c Mon Sep 17 00:00:00 2001 From: Christophe Barlieb Date: Tue, 19 May 2026 18:50:46 +0200 Subject: [PATCH 1/3] spiral: uniform mm-spacing toolpath via ray-cast sampler (Issue #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace FramesPerLayer (count) with FrameSpacingMm (arc-length distance) across the model, dialog, builders, and call sites. The old normalized- parameter sampling produced uneven physical spacing whenever contour perimeters varied; the new model gives uniform bead density regardless of part shape. SpiralInterpolator rewritten around a two-step plan: build one continuous on-surface spiral curve, then walk it from bottom to top at FrameSpacingMm. Helical pitch = the user's LayerHeight (one full turn per LayerHeight of Z rise). Surface fidelity comes from two changes: re-slicing the source brep/mesh internally at 0.5 mm pitch (independent of print layer height), and sampling each contour by ray-cast from its centroid at the target spiral angle — which handles non-convex / wavy contours correctly where arc-length parameter sampling cut across the part interior. Skirt and base contours also migrated: SampleSkirtFrames and BaseBuilder now derive sample count from perimeter / spacing so every closed loop prints at consistent bead density. Co-Authored-By: Claude Opus 4.7 (1M context) --- ARCHITECTURE.md | 2 +- CCL_Clay3DP/Core/BaseBuilder.cs | 26 +- CCL_Clay3DP/Core/SkirtBuilder.cs | 21 +- CCL_Clay3DP/Core/SpiralInterpolator.cs | 395 ++++++++++++++++++------ CCL_Clay3DP/Models/Parameters.cs | 20 +- CCL_Clay3DP/Settings/SettingsDialog.cs | 28 +- CCL_Clay3DP/Settings/SettingsManager.cs | 8 + CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs | 46 ++- README.md | 5 +- 9 files changed, 405 insertions(+), 146 deletions(-) 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..d70ef0d 100644 --- a/CCL_Clay3DP/Core/SpiralInterpolator.cs +++ b/CCL_Clay3DP/Core/SpiralInterpolator.cs @@ -15,166 +15,357 @@ using System; using System.Collections.Generic; using Rhino.Geometry; +using Rhino.Geometry.Intersect; namespace CCL_Clay3DP.Core { + /// + /// Two-step spiral toolpath construction: + /// 1. Build one continuous on-surface spiral curve whose helical + /// pitch is the user's LayerHeight (one full revolution per + /// LayerHeight of Z). Surface fidelity comes from re-slicing + /// the source brep/mesh at a fine Z pitch; angular fidelity + /// comes from ray-casting each contour at the target spiral + /// angle (which works correctly on non-convex / wavy contours, + /// unlike arc-length parameter sampling). + /// 2. Walk that spiral curve from bottom to top at the user's + /// FrameSpacingMm. The resulting points are uniformly spaced + /// along arc length, on-surface, 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; + + /// + /// 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); - - // Align seam points so the spiral doesn't twist randomly - AlignSeamPoints(contours, startAngle); + // Step A: build one continuous spiral curve. - var spiralPoints = new List(); + // 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); - // Walk between consecutive contour pairs - int totalLayers = contours.Count - 1; - for (int layer = 0; layer < totalLayers; layer++) - { - progress?.Invoke(0.03 + (double)layer / totalLayers * 0.02); // 3-5% + // A2. Precompute centroids once. Every dense sample ray-casts + // from its contour's centroid at the target angle, so angular + // alignment stays consistent across contours regardless of + // shape — no seam alignment or winding fix-up needed. + var centroids = ComputeCentroids(workingContours); - var lower = contours[layer]; - var upper = contours[layer + 1]; + // A3. Sample the on-surface spiral. Spiral angle at height z + // is startAngle ± 2π · (z − zMin) / LayerHeight (sign from + // CCW/CW), so one full turn always corresponds to exactly + // LayerHeight of Z rise. + double startAngleRad = startAngleDegrees * Math.PI / 180.0; + var dense = BuildSpiralSamples(workingContours, centroids, + layerHeightMm, frameSpacingMm, startAngleRad, ccw, progress); + if (dense.Count < 2) return dense; - double lowerZ = ContourZ(lower); - double upperZ = ContourZ(upper); + // 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)); - double lowerLength = lower.GetLength(); - double upperLength = upper.GetLength(); + // Step B: walk the spiral curve from bottom to top at the + // user's frame spacing. + return ResampleByArcLength(spiralCurve, frameSpacingMm); + } - for (int i = 0; i < pointsPerLayer; i++) - { - // Normalized parameter along the contour [0, 1) - double t = (double)i / pointsPerLayer; + /// + /// 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)); + } - // Fraction through this layer for Z interpolation - double zFrac = t; - double z = lowerZ + (upperZ - lowerZ) * zFrac; + /// + /// Re-slice the source brep/mesh at + /// between the Z range of . Returns the + /// original list unchanged when refining isn't a fidelity gain. + /// + private static List RefineContoursIfPossible( + List contours, Brep brep, Mesh mesh, double pitchMm) + { + if (brep == null && mesh == null) return contours; + if (pitchMm <= 0.0) return contours; - // Sample both contours at the same normalized parameter - Point3d ptLower = SampleAtNormalized(lower, t); - Point3d ptUpper = SampleAtNormalized(upper, t); + double zMin = ContourZ(contours[0]); + double zMax = ContourZ(contours[contours.Count - 1]); + if (zMax - zMin <= pitchMm) return contours; - // 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; + double existingPitch = (zMax - zMin) / (contours.Count - 1); + if (pitchMm >= existingPitch) return contours; - spiralPoints.Add(new Point3d(x, y, z)); - } + var refined = new List(); + for (double z = zMin; z <= zMax + 1e-6; z += pitchMm) + { + 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); } - // Add the final point at the top of the last contour - var lastContour = contours[contours.Count - 1]; - spiralPoints.Add(SampleAtNormalized(lastContour, 0.0)); - - return spiralPoints; + return refined.Count >= 2 ? refined : contours; } /// - /// Create an interpolated curve through the spiral points. + /// 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 angular position is computed from + /// absolute Z and the helical pitch — so one full turn always + /// corresponds to LayerHeight of Z, regardless of whether a + /// contour pair spans a full turn (print pitch) or a fraction + /// of one (fine slicing). + /// + /// Within a pair, each sample is the chord blend between the two + /// bracketing contours, each ray-cast at the same angle from its + /// centroid. Chord deviation from the surface shrinks with the + /// contour spacing — fine slicing keeps it small even on wavy + /// organic meshes. /// - public static Curve CreateSpiralCurve(List points, int degree = 3) + private static List BuildSpiralSamples( + List contours, List centroids, + double layerHeightMm, double frameSpacingMm, + double startAngleRad, bool ccw, Action progress) { - if (points.Count < 2) - return null; + var dense = new List(); + int totalPairs = contours.Count - 1; + if (totalPairs < 1) return dense; - var curve = Curve.CreateInterpolatedCurve(points, degree); - return curve; - } + double zMin = ContourZ(contours[0]); + double zMax = ContourZ(contours[contours.Count - 1]); - /// - /// Ensure all contours wind in the same direction. - /// - private static void AlignContourDirections(List contours, bool ccw) - { - foreach (var contour in contours) + // 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) { - // CurveOrientation returns CounterClockwise or Clockwise - // when viewed from above (looking down -Z) - var orientation = contour.ClosedCurveOrientation(Vector3d.ZAxis); + 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)); - bool isCCW = orientation == CurveOrientation.CounterClockwise; - if (isCCW != ccw) - contour.Reverse(); + // Per-contour bbox diagonal cached so SampleByAngle doesn't + // recompute it on every sample. + var rayLengths = new double[contours.Count]; + for (int i = 0; i < contours.Count; i++) + { + var bb = contours[i].GetBoundingBox(true); + rayLengths[i] = bb.IsValid ? bb.Diagonal.Length * 2.0 + 100.0 : 1000.0; } - } - /// - /// 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. - /// - private static void AlignSeamPoints(List contours, double startAngleDegrees) - { - double angleRad = startAngleDegrees * Math.PI / 180.0; - var seamDir = new Vector3d(Math.Cos(angleRad), Math.Sin(angleRad), 0); + double windSign = ccw ? 1.0 : -1.0; - foreach (var contour in contours) + for (int pair = 0; pair < totalPairs; pair++) { - // Find the centroid of the contour - var areaProps = AreaMassProperties.Compute(contour); - if (areaProps == null) continue; + progress?.Invoke(0.03 + (double)pair / totalPairs * 0.02); - Point3d centroid = areaProps.Centroid; + var lower = contours[pair]; + var upper = contours[pair + 1]; + double zLo = ContourZ(lower); + double zHi = ContourZ(upper); + + for (int k = 0; k < samplesPerPair; k++) + { + double zFrac = (double)k / samplesPerPair; + double z = zLo + (zHi - zLo) * zFrac; + double angleRad = SpiralAngle(z, zMin, layerHeightMm, startAngleRad, windSign); - // Project a point outward from centroid in the seam direction - Point3d seamTarget = centroid + seamDir * 10000; - seamTarget = new Point3d(seamTarget.X, seamTarget.Y, centroid.Z); + Point3d? ptLo = SampleByAngle(lower, centroids[pair], rayLengths[pair], angleRad); + Point3d? ptHi = SampleByAngle(upper, centroids[pair + 1], rayLengths[pair + 1], angleRad); + if (!ptLo.HasValue || !ptHi.HasValue) continue; - // Find closest point on contour to the seam target direction - if (contour.ClosestPoint(seamTarget, out double t)) - contour.ChangeClosedCurveSeam(t); + double x = ptLo.Value.X + (ptHi.Value.X - ptLo.Value.X) * zFrac; + double y = ptLo.Value.Y + (ptHi.Value.Y - ptLo.Value.Y) * zFrac; + dense.Add(new Point3d(x, y, z)); + } } + + // Finish on the top contour at its spiral angle so the curve + // terminates on the actual surface, not mid-blend. + int topIdx = contours.Count - 1; + double angleTop = SpiralAngle(zMax, zMin, layerHeightMm, startAngleRad, windSign); + var topPt = SampleByAngle(contours[topIdx], centroids[topIdx], + rayLengths[topIdx], angleTop); + if (topPt.HasValue) dense.Add(topPt.Value); + + return dense; + } + + private static double SpiralAngle(double z, double zMin, double layerHeightMm, + double startAngleRad, double windSign) + { + double turnFrac = (z - zMin) / layerHeightMm; + return startAngleRad + windSign * turnFrac * 2.0 * Math.PI; } /// - /// Sample a closed curve at a normalized parameter [0, 1). - /// Uses arc-length parameterization for even spacing. + /// Find the outermost intersection of a ray from + /// at with the contour. "Outermost" is the + /// farthest hit along the ray, which is the surface point in that + /// angular direction even when the contour is non-convex and the + /// ray would otherwise stab inward concavities first. /// - private static Point3d SampleAtNormalized(Curve curve, double normalizedT) + private static Point3d? SampleByAngle(Curve contour, Point3d centroid, + double rayLength, double angleRad) { - double length = curve.GetLength(); - double targetLength = normalizedT * length; + var dir = new Vector3d(Math.Cos(angleRad), Math.Sin(angleRad), 0); + var rayEnd = centroid + dir * rayLength; + rayEnd = new Point3d(rayEnd.X, rayEnd.Y, centroid.Z); + var ray = new LineCurve(centroid, rayEnd); - if (!curve.LengthParameter(targetLength, out double param)) + var events = Intersection.CurveCurve(ray, contour, 0.001, 0.001); + if (events == null || events.Count == 0) return null; + + double farthestParam = double.MinValue; + Point3d best = Point3d.Unset; + for (int i = 0; i < events.Count; i++) { - // Fallback: use domain-based parameter - double domainT = curve.Domain.ParameterAt(normalizedT); - return curve.PointAt(domainT); + var ev = events[i]; + if (!ev.IsPoint) continue; + if (ev.ParameterA > farthestParam) + { + farthestParam = ev.ParameterA; + best = ev.PointA; + } } + return best.IsValid ? best : (Point3d?)null; + } - return curve.PointAt(param); + private static List ComputeCentroids(List contours) + { + var list = new List(contours.Count); + foreach (var c in contours) + { + var amp = AreaMassProperties.Compute(c); + if (amp != null) + { + // Pin centroid to the contour's Z plane — AreaMassProperties + // can drift by float noise. + var ctr = amp.Centroid; + list.Add(new Point3d(ctr.X, ctr.Y, c.PointAtStart.Z)); + } + else + { + // Degenerate contour: bbox center is the safest fallback. + var bb = c.GetBoundingBox(true); + list.Add(bb.IsValid ? bb.Center : c.PointAtStart); + } + } + return list; } /// - /// 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) > 1e-3) + 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..cce6f02 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,7 +1439,7 @@ 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); @@ -1442,9 +1468,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. @@ -1587,7 +1613,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( 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 From 96f8d26430e441ff9d150dcf04f05a545eaa1ab0 Mon Sep 17 00:00:00 2001 From: Christophe Barlieb Date: Tue, 19 May 2026 19:13:24 +0200 Subject: [PATCH 2/3] layer-mode: uniform mm-spacing on AddCurvePoints (Issue #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddCurvePoints used polyline vertices for polylines and a hardcoded 2 mm step for smooth curves. Replaced with uniform DivideByLength resampling at FrameSpacingMm so layer-mode toolpaths (outer contour + bracing zigzag) print at the same bead density as the spiral. Closed curves get the duplicate seam tick trimmed so frame tangents don't degenerate at the seam. Base infill remains on vertex sampling — its U-turn corners are direction changes the robot must hit exactly, so resampling would round them off. If uniform spacing is wanted there too, that's a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs | 60 +++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs index cce6f02..3950daa 100644 --- a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs +++ b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs @@ -1446,7 +1446,8 @@ private void RunLayerSlice(GeometrySelection selection) // 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 { @@ -1578,9 +1579,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; @@ -1655,25 +1656,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); } /// From 8b7c9aca3d23a26511c292243b364fa5ce77b3b7 Mon Sep 17 00:00:00 2001 From: Christophe Barlieb Date: Tue, 19 May 2026 21:48:32 +0200 Subject: [PATCH 3/3] spiral + bracing: shared-centroid seam alignment, constrained projection, dedup helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spiral toolpath (Issue #22): - Replace per-contour-centroid ray-cast sampler with arc-length parameter sampling. Ray-cast was correct on convex contours but short-circuited across the interior on multi-lobe / wavy non-convex shapes. Arc-length parameterization walks the actual perimeter. - Restore AlignContourDirections + AlignSeamPoints, but anchor seam alignment to a SHARED centroid (mean of all per-layer centroids) so parameter t=0 maps to the same angular column on every contour. - Add constrained lateral projection: each chord-blend sample is projected onto the brep/mesh surface, but only when the closest surface point is within LayerHeight/2 in Z. Steep walls get hugged; near-horizontal regions fall back to chord (the price of staying on a printable monotonic-Z spiral). Sinusoidal bracing: - Take an optional shared centroid for seam alignment so the cosine peaks stack vertically across layers instead of wandering. Phase stays arc-length-based so peaks remain evenly distributed along each layer's perimeter. - Panel computes the shared centroid once and passes it to both the toolpath generation loop and the preview-arrow bake. Cleanup from review pass: - Promote SpiralInterpolator.ComputeSharedCentroid to public, delete the duplicate in CCL_Clay3DPPanel. - Replace magic 10000.0 (seam ray distance) and 1e-3 (dedup tolerance) with named constants. - RefineContoursIfPossible always returns a fresh list now — callers mutate the result via Align* and must not corrupt input contour data. - Strip narration comments that just restated the code. Co-Authored-By: Claude Opus 4.7 (1M context) --- CCL_Clay3DP/Core/SpiralInterpolator.cs | 305 ++++++++++++++++--------- CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs | 15 +- CCL_Clay3DP/Zigzag/ZigzagGenerator.cs | 56 +++-- 3 files changed, 249 insertions(+), 127 deletions(-) diff --git a/CCL_Clay3DP/Core/SpiralInterpolator.cs b/CCL_Clay3DP/Core/SpiralInterpolator.cs index d70ef0d..e2b7f32 100644 --- a/CCL_Clay3DP/Core/SpiralInterpolator.cs +++ b/CCL_Clay3DP/Core/SpiralInterpolator.cs @@ -15,22 +15,22 @@ using System; using System.Collections.Generic; using Rhino.Geometry; -using Rhino.Geometry.Intersect; namespace CCL_Clay3DP.Core { /// /// Two-step spiral toolpath construction: - /// 1. Build one continuous on-surface spiral curve whose helical - /// pitch is the user's LayerHeight (one full revolution per - /// LayerHeight of Z). Surface fidelity comes from re-slicing - /// the source brep/mesh at a fine Z pitch; angular fidelity - /// comes from ray-casting each contour at the target spiral - /// angle (which works correctly on non-convex / wavy contours, - /// unlike arc-length parameter sampling). - /// 2. Walk that spiral curve from bottom to top at the user's - /// FrameSpacingMm. The resulting points are uniformly spaced - /// along arc length, on-surface, and Z-monotonic. + /// 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 { @@ -59,6 +59,21 @@ public static class SpiralInterpolator /// 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 @@ -110,19 +125,27 @@ public static List Interpolate( var workingContours = RefineContoursIfPossible( contours, brep, mesh, pathSlicePitchMm); - // A2. Precompute centroids once. Every dense sample ray-casts - // from its contour's centroid at the target angle, so angular - // alignment stays consistent across contours regardless of - // shape — no seam alignment or winding fix-up needed. - var centroids = ComputeCentroids(workingContours); - - // A3. Sample the on-surface spiral. Spiral angle at height z - // is startAngle ± 2π · (z − zMin) / LayerHeight (sign from - // CCW/CW), so one full turn always corresponds to exactly - // LayerHeight of Z rise. - double startAngleRad = startAngleDegrees * Math.PI / 180.0; - var dense = BuildSpiralSamples(workingContours, centroids, - layerHeightMm, frameSpacingMm, startAngleRad, ccw, progress); + // 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 @@ -148,21 +171,24 @@ public static Curve CreateSpiralCurve(List points) /// /// Re-slice the source brep/mesh at - /// between the Z range of . Returns the - /// original list unchanged when refining isn't a fidelity gain. + /// 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 contours; - if (pitchMm <= 0.0) return contours; + 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 contours; + if (zMax - zMin <= pitchMm) return new List(contours); double existingPitch = (zMax - zMin) / (contours.Count - 1); - if (pitchMm >= existingPitch) return contours; + if (pitchMm >= existingPitch) return new List(contours); var refined = new List(); for (double z = zMin; z <= zMax + 1e-6; z += pitchMm) @@ -176,28 +202,31 @@ private static List RefineContoursIfPossible( refined.Add(sliced); } - return refined.Count >= 2 ? refined : contours; + return refined.Count >= 2 ? refined : new List(contours); } /// /// 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 angular position is computed from - /// absolute Z and the helical pitch — so one full turn always - /// corresponds to LayerHeight of Z, regardless of whether a - /// contour pair spans a full turn (print pitch) or a fraction - /// of one (fine slicing). + /// 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. /// - /// Within a pair, each sample is the chord blend between the two - /// bracketing contours, each ray-cast at the same angle from its - /// centroid. Chord deviation from the surface shrinks with the - /// contour spacing — fine slicing keeps it small even on wavy - /// organic meshes. + /// 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, List centroids, + List contours, double layerHeightMm, double frameSpacingMm, - double startAngleRad, bool ccw, Action progress) + Brep brep, Mesh mesh, + Action progress) { var dense = new List(); int totalPairs = contours.Count - 1; @@ -223,16 +252,14 @@ private static List BuildSpiralSamples( int samplesPerPair = Math.Max(MinSamplesPerContourPair, (int)Math.Ceiling(maxPerimeter * turnFractionPerPair / targetSpacingMm)); - // Per-contour bbox diagonal cached so SampleByAngle doesn't - // recompute it on every sample. - var rayLengths = new double[contours.Count]; - for (int i = 0; i < contours.Count; i++) - { - var bb = contours[i].GetBoundingBox(true); - rayLengths[i] = bb.IsValid ? bb.Diagonal.Length * 2.0 + 100.0 : 1000.0; - } - - double windSign = ccw ? 1.0 : -1.0; + // 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++) { @@ -247,90 +274,158 @@ private static List BuildSpiralSamples( { double zFrac = (double)k / samplesPerPair; double z = zLo + (zHi - zLo) * zFrac; - double angleRad = SpiralAngle(z, zMin, layerHeightMm, startAngleRad, windSign); - Point3d? ptLo = SampleByAngle(lower, centroids[pair], rayLengths[pair], angleRad); - Point3d? ptHi = SampleByAngle(upper, centroids[pair + 1], rayLengths[pair + 1], angleRad); - if (!ptLo.HasValue || !ptHi.HasValue) continue; + double turns = (z - zMin) / layerHeightMm; + double t = turns - Math.Floor(turns); - double x = ptLo.Value.X + (ptHi.Value.X - ptLo.Value.X) * zFrac; - double y = ptLo.Value.Y + (ptHi.Value.Y - ptLo.Value.Y) * zFrac; - dense.Add(new Point3d(x, y, z)); + Point3d ptLo = SampleAtArcLengthParameter(lower, t); + Point3d ptHi = SampleAtArcLengthParameter(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); + + // 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); + + dense.Add(pt); } } - // Finish on the top contour at its spiral angle so the curve - // terminates on the actual surface, not mid-blend. - int topIdx = contours.Count - 1; - double angleTop = SpiralAngle(zMax, zMin, layerHeightMm, startAngleRad, windSign); - var topPt = SampleByAngle(contours[topIdx], centroids[topIdx], - rayLengths[topIdx], angleTop); - if (topPt.HasValue) dense.Add(topPt.Value); + // 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 dense; } - private static double SpiralAngle(double z, double zMin, double layerHeightMm, - double startAngleRad, double windSign) + /// + /// 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. + /// + private static Point3d LaterallyProjectIfSafe(Point3d chord, Brep brep, + Mesh mesh, double zTolerance) { - double turnFrac = (z - zMin) / layerHeightMm; - return startAngleRad + windSign * turnFrac * 2.0 * Math.PI; + 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; + + if (Math.Abs(surface.Z - chord.Z) > zTolerance) return chord; + return new Point3d(surface.X, surface.Y, chord.Z); } /// - /// Find the outermost intersection of a ray from - /// at with the contour. "Outermost" is the - /// farthest hit along the ray, which is the surface point in that - /// angular direction even when the contour is non-convex and the - /// ray would otherwise stab inward concavities first. + /// 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? SampleByAngle(Curve contour, Point3d centroid, - double rayLength, double angleRad) + private static Point3d SampleAtArcLengthParameter(Curve curve, double t) { - var dir = new Vector3d(Math.Cos(angleRad), Math.Sin(angleRad), 0); - var rayEnd = centroid + dir * rayLength; - rayEnd = new Point3d(rayEnd.X, rayEnd.Y, centroid.Z); - var ray = new LineCurve(centroid, rayEnd); + 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) + { + var orientation = contour.ClosedCurveOrientation(Vector3d.ZAxis); + bool isCCW = orientation == CurveOrientation.CounterClockwise; + if (isCCW != ccw) + contour.Reverse(); + } + } - var events = Intersection.CurveCurve(ray, contour, 0.001, 0.001); - if (events == null || events.Count == 0) return null; + /// + /// 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, + Point3d sharedCentroid, double startAngleDegrees) + { + double angleRad = startAngleDegrees * Math.PI / 180.0; + var seamDir = new Vector3d(Math.Cos(angleRad), Math.Sin(angleRad), 0); - double farthestParam = double.MinValue; - Point3d best = Point3d.Unset; - for (int i = 0; i < events.Count; i++) + foreach (var contour in contours) { - var ev = events[i]; - if (!ev.IsPoint) continue; - if (ev.ParameterA > farthestParam) - { - farthestParam = ev.ParameterA; - best = ev.PointA; - } + 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); } - return best.IsValid ? best : (Point3d?)null; } - private static List ComputeCentroids(List contours) + /// + /// 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. + /// + public static Point3d ComputeSharedCentroid(List contours) { - var list = new List(contours.Count); + double sumX = 0.0, sumY = 0.0; + int n = 0; foreach (var c in contours) { + if (c == null) continue; + Point3d centre; var amp = AreaMassProperties.Compute(c); if (amp != null) { - // Pin centroid to the contour's Z plane — AreaMassProperties - // can drift by float noise. - var ctr = amp.Centroid; - list.Add(new Point3d(ctr.X, ctr.Y, c.PointAtStart.Z)); + centre = amp.Centroid; } else { - // Degenerate contour: bbox center is the safest fallback. var bb = c.GetBoundingBox(true); - list.Add(bb.IsValid ? bb.Center : c.PointAtStart); + if (!bb.IsValid) continue; + centre = bb.Center; } + sumX += centre.X; + sumY += centre.Y; + n++; } - return list; + return n > 0 ? new Point3d(sumX / n, sumY / n, 0.0) : Point3d.Origin; } /// @@ -360,7 +455,7 @@ private static List ResampleByArcLength(Curve curve, double spacingMm) } Point3d end = curve.PointAtEnd; - if (pts.Count == 0 || pts[pts.Count - 1].DistanceTo(end) > 1e-3) + if (pts.Count == 0 || pts[pts.Count - 1].DistanceTo(end) > PointDedupTolMm) pts.Add(end); return pts; diff --git a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs index 3950daa..deacbdb 100644 --- a/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs +++ b/CCL_Clay3DP/UI/CCL_Clay3DPPanel.cs @@ -1539,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); @@ -2098,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; @@ -2105,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]);