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