cc @anthrotype: this is claude's reasoning about a particular diff in Unna.glyphs, but I don't feel like I fully understand the issue. Specifically, it isn't obvious to me why it is useful to had a wdth axis at all in this font, if it isn't variable? Fontmake is adding an extra axis, which has some binary and complexity costs, and that axis isn't doing anything. Is that helpful?
claude's output below, which is assuming that fontmake is right:
Reproduction
python3 -m ttx_diff 'https://github.com/Omnibus-Type/Unna?826be2abeb#sources/Unna.glyphs'
Affected tables: fvar, STAT, avar, HVAR, MVAR, name
Summary
When a Glyphs source has an axis where all masters share the same design-space position but a non-identity user-to-design mapping exists, fontc drops the axis entirely. fontmake correctly emits it as a point axis with the mapped user-space coordinates.
Details
The Unna font is a Glyphs v2 source with 2 masters (Regular and Bold). Both masters sit at widthValue=5. The font has no explicit "Axes" custom parameter, so it gets the default axes (wght, wdth, custom).
Instance widthClass values produce a non-identity wdth mapping: user 100 → design 5. Despite there being no variation along this axis, the mapping is semantically meaningful — it defines the user-space coordinate system. The correct output is a point axis in fvar with min=100, default=100, max=100 and an avar mapping entry (100, 5).
fontc omits the axis completely.
fontc code path
In glyphs2fontir/src/toir.rs:203-205, the has_non_identity_mapping check has three conditions:
let has_non_identity_mapping = font.axis_mappings.contains(&axis.name)
&& !font.axis_mappings.get(&axis.name).unwrap().is_identity()
&& min != max;
For wdth, both masters have widthValue=5, so min == max == DesignCoord(5). The third condition min != max is false, making the entire expression false. The code falls through to the unmapped branch (line 220-225):
let min = UserCoord::new(min.into_inner()); // 5.0
let max = UserCoord::new(max.into_inner()); // 5.0
let default = UserCoord::new(default.into_inner()); // 5.0
CoordConverter::unmapped(min, default, max)
The axis becomes min=5, default=5, max=5 in user space (raw design units, not the CSS percentage scale). Then in fontir/src/ir/static_metadata.rs:400:
let variable_axes: Axes = axes.iter().filter(|a| !a.is_point()).cloned().collect();
This filters out all point axes. The wdth axis is gone from fvar, STAT, avar, and name.
fontmake code path
In glyphsLib/builder/axes.py:288-293, the axis inclusion condition is:
if (
minimum < maximum
or minimum != axis_def.default_user_loc
or not is_identity_map
or axis_wanted
):
The not is_identity_map condition catches this case. The mapping {100: 5} is non-identity, so the axis IS included despite minimum == maximum. The axis is emitted with min=100, default=100, max=100 and a mapping entry (100, 5).
Why the min != max guard is wrong
The comment above the check (lines 199-202) explains the intent:
// If all masters sit at the same position on this axis, the mapping is
// meaningless and there's no variation to map. Treat as unmapped.
This conflates two distinct purposes of the axis mapping:
- Mapping variation deltas between coordinate systems — this is indeed meaningless when there's no variation.
- Defining the user-space coordinate system for fvar/STAT — this is meaningful regardless of variation. A point axis at
wdth=100 (CSS percentage) is semantically different from one at wdth=5 (internal design units).
Downstream effects
| Table |
fontc |
fontmake |
| fvar |
1 axis (wght) |
2 axes (wght, wdth) |
| STAT |
DesignAxisCount=1 |
DesignAxisCount=2 |
| avar |
no wdth segment |
wdth identity segment |
| HVAR/MVAR |
RegionAxisCount=1 |
RegionAxisCount=2 |
| name |
no "Width" axis name |
has "Width" axis name |
cc @anthrotype: this is claude's reasoning about a particular diff in Unna.glyphs, but I don't feel like I fully understand the issue. Specifically, it isn't obvious to me why it is useful to had a
wdthaxis at all in this font, if it isn't variable? Fontmake is adding an extra axis, which has some binary and complexity costs, and that axis isn't doing anything. Is that helpful?claude's output below, which is assuming that fontmake is right:
Reproduction
python3 -m ttx_diff 'https://github.com/Omnibus-Type/Unna?826be2abeb#sources/Unna.glyphs'Affected tables:
fvar,STAT,avar,HVAR,MVAR,nameSummary
When a Glyphs source has an axis where all masters share the same design-space position but a non-identity user-to-design mapping exists, fontc drops the axis entirely. fontmake correctly emits it as a point axis with the mapped user-space coordinates.
Details
The Unna font is a Glyphs v2 source with 2 masters (Regular and Bold). Both masters sit at
widthValue=5. The font has no explicit "Axes" custom parameter, so it gets the default axes (wght, wdth, custom).Instance widthClass values produce a non-identity
wdthmapping: user 100 → design 5. Despite there being no variation along this axis, the mapping is semantically meaningful — it defines the user-space coordinate system. The correct output is a point axis in fvar withmin=100, default=100, max=100and an avar mapping entry(100, 5).fontc omits the axis completely.
fontc code path
In
glyphs2fontir/src/toir.rs:203-205, thehas_non_identity_mappingcheck has three conditions:For
wdth, both masters havewidthValue=5, somin == max == DesignCoord(5). The third conditionmin != maxis false, making the entire expression false. The code falls through to the unmapped branch (line 220-225):The axis becomes
min=5, default=5, max=5in user space (raw design units, not the CSS percentage scale). Then infontir/src/ir/static_metadata.rs:400:This filters out all point axes. The wdth axis is gone from fvar, STAT, avar, and name.
fontmake code path
In
glyphsLib/builder/axes.py:288-293, the axis inclusion condition is:The
not is_identity_mapcondition catches this case. The mapping{100: 5}is non-identity, so the axis IS included despiteminimum == maximum. The axis is emitted withmin=100, default=100, max=100and a mapping entry(100, 5).Why the
min != maxguard is wrongThe comment above the check (lines 199-202) explains the intent:
This conflates two distinct purposes of the axis mapping:
wdth=100(CSS percentage) is semantically different from one atwdth=5(internal design units).Downstream effects