Skip to content

Derive per-path line height and extrusion width from the moves - #462

Draft
sophiedeziel wants to merge 8 commits into
developfrom
cura-derived-dimensions
Draft

sophiedeziel wants to merge 8 commits into
developfrom
cura-derived-dimensions

Conversation

@sophiedeziel

@sophiedeziel sophiedeziel commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A file that announces no ;WIDTH: / ;HEIGHT: comments gets one global width and height for the whole print, however much its extrusions actually vary. Everything needed to reconstruct them is already in the moves, so this derives them:

  • Height is the Z step between consecutive extrusion moves, which makes z-hop travels invisible to the measurement.
  • Width comes from conservation of volume — ΔE × filament cross-section / (length × height) — using a rectangular deposit, matching the box profile ExtrusionGeometry actually extrudes.

Deriving is the default for every dialect, not a Cura feature: Cura is the motivating case, but Simplify3D, Slic3r without verbose comments and hand-written gcode all lack dimension comments too. A dialect only opts out when the arithmetic itself does not hold (Cura's UltiGCode flavor, whose E is cubic millimetres of material rather than millimetres of filament).

Precedence per dimension is announced > supplied > derived > built-in default. The slicer stating what it asked the printer for outranks everything; a value you pass to the constructor is a decision and outranks the inference, so the derivation simply does not run for it; deriving fills what is left. That keeps extrusionWidth and lineHeight meaningful options rather than ones the derivation quietly takes over.

That precedence is also what keeps streaming honest. Gating on "this file announced no dimensions" would decide mid-stream: a PrusaSlicer file whose only fingerprint is ;LAYER_CHANGE is identified after its start gcode has extruded a prime line, so a streamed parse would derive for that path while a one-shot parse would not. Nothing flips here, so streamed and one-shot agree by construction.

Stacked on #461, which supplies the true extruded lengths this needs.

Behavior changes

  • Files without dimension comments now render with per-path widths and heights instead of the global fallback.
  • Files that announce only some dimensions get the rest derived — a PrusaSlicer print's skirt and prime line, emitted before the first ;WIDTH:, now carry a derived width, unless the caller supplied one.
  • Header metadata — the filament diameter and the opt-out — is read from the file's first 200 comments rather than from whichever chunk identified the slicer. Cura writes ;FLAVOR: before the line that identifies it, so under streaming those could land in different chunks: Griffin silently lost its 2.85 mm diameter, scaling every derived width by (2.85/1.75)² = 2.65×, and UltiGCode wrongly enabled derivation.

Implausible results are discarded rather than rendered: heights outside 0.01–1.0 mm, widths outside 0.1–2.0 mm, and segments under 0.05 mm where E quantization dominates. Derived widths are quantized to 0.01 mm with a 2% tolerance so rounding noise cannot shatter the model into micro-paths.

Deriving costs nothing on files that announce their widths — the volume arithmetic is skipped while an announced width is in force, which is worth about a third of the interpret time on 3DBenchy.

Public API changes

Two new overridable hooks on SlicerMetadataParser: derivesExtrusionDimensions (defaults to true; a dialect returns false to opt out) and parseFilamentDiameter. Metadata gains optional deriveExtrusionDimensions and filamentDiameter. State is unchanged: the derived dimensions live inside Job, since nothing outside it reads them. Its surface is now pinned in the public API test all the same, being reachable as job.state. Job accepts extrusionWidth / lineHeight, the dimensions the caller supplied, and Job.deriveMoveDimensions is new. No renames or removals.

Screenshots

Five first-layer lines printed at 1.2 / 0.9 / 0.6 / 0.4 / 0.25 mm, same camera:

Before After
five parallel first-layer lines, all rendered at an identical 0.6 mm width the same five lines with derived widths, tapering from 1.2 mm down to a hairline 0.25 mm

Heights come out of the Z steps, so the tower's three tiers stack visibly differently — 0.3 mm at the bottom, 0.2 mm in the middle, 0.1 mm on top:

close-up of the tower wall: four fat beads in the bottom tier, eight medium in the middle, about twenty hairline beads on top

Checks

  • npm run check passes (test + typeCheck + lint)
  • npm run test:coverage — src/ still at 100%
  • Labelled appropriately (enhancement)

Assisted by Claude Code - Fable 5

@sophiedeziel sophiedeziel added the enhancement Improvement to an existing feature label Sep 7, 2026
@sophiedeziel sophiedeziel added the parser Related to G-code parsing label Sep 7, 2026
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from 388e983 to 69c7723 Compare September 7, 2026 07:14
@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit c68460c):

https://gcode-preview--pr462-cura-derived-dimensi-p415l5p1.web.app

(expires Wed, 07 Oct 2026 21:19:34 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 59bd114ae4847b32c2bba0b68620b9069a3e3531

@sophiedeziel
sophiedeziel marked this pull request as draft September 7, 2026 07:17
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch 2 times, most recently from a4cb4d8 to b2b36be Compare September 7, 2026 16:46
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from b2b36be to ab12f4f Compare September 7, 2026 17:05
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from ab12f4f to f5a118b Compare September 7, 2026 17:19
sophiedeziel added a commit that referenced this pull request Sep 7, 2026
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from f5a118b to 032f5bc Compare September 7, 2026 18:01
@sophiedeziel sophiedeziel changed the title Derive per-path line height and extrusion width for Cura gcode Derive per-path line height and extrusion width from the moves Sep 7, 2026
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from 24c77b0 to cabb24c Compare September 7, 2026 18:29
Base automatically changed from extrusion-mode-commands to develop September 7, 2026 18:33
- SlicerMetadataParser gains derivesExtrusionDimensions and
  parseFilamentDiameter, both defaulting to no-ops; dialects that announce
  exact ;WIDTH:/;HEIGHT: comments are unaffected.
- CuraMetadataParser opts in: Cura emits no dimension comments at all, so
  per-path width and height can only be reconstructed from the moves. The
  volumetric UltiGCode flavor is excluded (its E values are mm3 of material,
  not mm of filament).
- The filament diameter is read from the header ;FLAVOR: line only: Griffin
  is exclusive to Ultimaker's 2.85mm machines. Cura's own material_diameter
  setting sits in the ;SETTING_3 blob at the end of the file, after every
  move it would apply to; using it would reach a one-shot parse before any
  move executes but a streamed parse only after every move already ran,
  breaking streamed/one-shot equivalence, so it is deliberately not used.
- Parser.parseGCode forwards both as metadata.deriveExtrusionDimensions and
  metadata.filamentDiameter, evaluated once on the chunk that identified the
  slicer, like detectSlicerName.
- Job.deriveMoveDimensions runs on every extruding linear move when the
  metadata asked for derivation, before continuePath — so a derived change
  breaks the path exactly like a ;WIDTH:/;HEIGHT: comment would, and the
  derived values take the same render-time precedence over the global
  fallback (they are the path's own values).
- Layer height is the Z step between consecutive extrusion moves; the first
  move's Z stands in for the first layer. Comparing extrusion Zs only makes
  z-hop travels invisible; a zero step (ironing) or a step outside
  0.01-1.0mm (spiralize's micro-climb, sequential objects) keeps the
  current height while re-anchoring the reference Z.
- Width comes from conservation of volume with a rectangular deposit
  cross-section — width = deltaE x filament cross-section / (length x
  height) — matching the box profile ExtrusionGeometry extrudes. The
  filament cross-section comes from metadata.filamentDiameter (1.75mm when
  unannounced). Results are quantized to 0.01mm and held within a 2%
  relative tolerance of the current width so E-value rounding noise cannot
  shatter the model into micro-paths; implausible results (outside
  0.1-2.0mm, or over a segment shorter than 0.05mm) are discarded so the
  path falls back to the global setting.
- All derivation state (extruder position, last extrusion Z, current
  derived dimensions) lives on Job/State, which persist across streamed
  chunks: the ingestion-equivalence harness gains a Cura fixture whose
  layer change and width shift land mid-chunk in every chunked mode.
- Round-trip tests generate Cura-style gcode from known target dimensions
  via the inverted volumetric formula and assert the derived per-path
  values recover them, covering adaptive layer heights, z-hops, G92 E
  resets, relative extrusion (M83), ironing and spiralize.
Security review finding: coordinates and E values that are individually
finite (and so survive the parser's param validation) can overflow the
derivation's intermediate arithmetic — Math.hypot to Infinity, or NaN via
Inf - Inf — and Infinity/Infinity makes the width NaN, which a plain
less-than range check waves through and latches into the state, breaking
the path on every extruding move after it. Guard the length with
Number.isFinite and write the width plausibility check in inclusive form
(matching the height check) so NaN is rejected.
The filament diameter and the derive-dimensions flag are read from the
;FLAVOR: line, which Cura writes first -- but the comment that identifies
the slicer comes a few lines later, so a streamed parse could identify on
a chunk that no longer held the flavor. The answers then depended on
chunk size: Griffin lost its 2.85mm diameter, scaling every derived width
by (2.85/1.75)^2 = 2.65x, and an UltiGCode file wrongly enabled the
derivation its volumetric E values do not fit.

The parser now retains the file's first 200 comments across chunks and
asks the header questions against that sample.
Deriving dimensions from the moves is useful for any gcode that does not
announce them -- Simplify3D, Slic3r without verbose comments, hand-written
files -- so it is now the default, and a dialect only opts out when the
arithmetic does not hold (Cura's UltiGCode, whose E is cubic millimetres).

Gating on "the file announced no dimensions" would have been the obvious
way to say that, but it decides mid-stream: a Prusa file whose only
fingerprint is ;LAYER_CHANGE is identified after its start gcode has
already extruded a prime line, so a streamed parse would derive for that
path and a one-shot parse would not. Nothing flips instead -- derivation
always runs, and an announced dimension outranks the derived one path by
path (State.resolvedExtrusionWidth), which is chunk-order independent.

The width derivation is skipped while a width is announced, since it
would lose the comparison anyway: without that, deriving cost a third of
the interpret time on 3DBenchy for byte-identical output.
@sophiedeziel
sophiedeziel force-pushed the cura-derived-dimensions branch from cabb24c to 78e9b2b Compare September 7, 2026 18:33
The docs and test names still framed deriving as something Cura opts
into, which stopped being true when it became the default for every
dialect. The remaining mentions are the ones that earn their place:
the Cura parser itself, and Cura as the concrete example of a dialect
whose E is volumetric or whose header arrives before its fingerprint.
Asking for an extrusion width through the public API is a decision;
deriving one from the moves is an inference, and the decision should
win. The derivation now skips whichever dimension the preview was
constructed with, so those paths carry no dimension of their own and
the supplied value applies at render time. The slicer's own ;WIDTH: /
;HEIGHT: comments still outrank both.

A supplied height still feeds the width derivation, which needs the
height the material was laid at rather than an inferred one, and the Z
anchor keeps advancing even when the height is not being derived -- a
test now covers that, since hoisting the width shortcut above the
height block silently passed the whole suite before.

Also pins State's surface in the public API test: it is reachable as
job.state, so its dimension fields are public whether or not the class
is exported.
The derived width and height sat on State next to the announced pair,
but nothing outside Job ever read or wrote them: the derivation writes
them, breakPath and continuePath read them, and that is the whole story.
On State they were public surface -- reachable as job.state -- and a
rename would have been a breaking change for a value that is really the
Job's working note.

Two fields are still worth keeping over one plus a provenance flag:
announcements arrive from beginCommand and derived values from the move
handler, so separate slots let them be written in any order without one
clobbering the other.

The derivation tests now assert through the dimensions a path actually
carries rather than through those fields, which is the behaviour they
meant to describe anyway.
@sophiedeziel sophiedeziel added the 3.1+ Targeted for the 3.1 release or later label Sep 10, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.1+ Targeted for the 3.1 release or later enhancement Improvement to an existing feature parser Related to G-code parsing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant