From 878d69cae2e48ebc19f6a0012f13160223e20647 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 17 May 2026 20:34:01 +0200 Subject: [PATCH 1/2] fix(line): clamp thickness <= length to avoid ^GB promotion Zebra firmware promotes ^GB w,h,t with t > min(w,h) to max(w,t) x max(h,t), so a vertical line emitted as ^GB t,length,t prints as a t x t square once thickness exceeds length rather than the declared length x t band. Enforce the t <= length invariant at every edit path: - Thickness side-handle drag caps at p.length - Property panel thickness gets max=p.length, length gets min=p.thickness (NumberInput already clamps typed values, so the cap is authoritative) - Start/end handle drag commits cap thickness to the new length so shrinking a line below its current thickness pulls thickness down rather than violating the invariant Existing stored lines with t > length keep their data and continue to print the promoted square; touching any handle or input snaps them back into a valid state. An explicit import-time warning for this case will follow in a separate change. --- src/components/Canvas/LineObject.tsx | 28 +++++++++++++++++++++++----- src/registry/line.tsx | 9 ++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 6d6f6cfd..1bbda1b3 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -459,10 +459,18 @@ export function LineObject({ e.target.getParent(), ); clearSnap(); + // Shrinking the line below the current thickness would + // push the ZPL into the `^GB` promotion regime (t > length + // → printed `t × t`); cap thickness to the new length so + // the model preserves the t ≤ length invariant. onChange({ x: r.movingDotX, y: r.movingDotY, - props: { length: r.length, angle: r.angle }, + props: { + length: r.length, + angle: r.angle, + thickness: Math.min(p.thickness, r.length), + }, }); }} /> @@ -520,7 +528,13 @@ export function LineObject({ e.target.getParent(), ); clearSnap(); - onChange({ props: { length: r.length, angle: r.angle } }); + onChange({ + props: { + length: r.length, + angle: r.angle, + thickness: Math.min(p.thickness, r.length), + }, + }); }} /> = { length prints `t × t`. + min={p.thickness} onChange={(length) => onChange({ length })} /> = { label={t.registry.line.thickness} value={p.thickness} min={1} + // Capped at length so the ZPL output stays out of the ^GB + // promotion regime (max(w, t) × max(h, t)), where the printer + // would extend the line beyond its declared length. + max={p.length} onChange={(thickness) => onChange({ thickness })} /> From ba80356f449a8f5529091fd54bb98a522c3daed6 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 17 May 2026 22:30:25 +0200 Subject: [PATCH 2/2] fix(line): cap visual stroke + auto-clamp thickness from length input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from Gemini review on PR #71: - LineObject visual stroke is now capped at the live endpoint distance so an endpoint-shrink no longer briefly renders a band wider than the line is long. The data model invariant (t <= length) already holds in steady state, so the cap is a no-op outside of an active drag. - Length input drops the min=p.thickness floor; instead the onChange shrinks thickness alongside length, matching the endpoint-handle commit. Thickness max=p.length stays — the inverse direction (typing thickness larger than length would auto-grow length) isn't a natural user intent. --- src/components/Canvas/LineObject.tsx | 14 +++++++++++--- src/registry/line.tsx | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 1bbda1b3..5e90f738 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -115,7 +115,7 @@ export function LineObject({ // time without any one-frame delay on release. const [liveThicknessDots, setLiveThicknessDots] = useState(null); const effectiveThicknessDots = liveThicknessDots ?? p.thickness; - const lineStrokeWidth = Math.max(dotsToPx(effectiveThicknessDots, scale, dpmm), 1); + const rawStrokePx = Math.max(dotsToPx(effectiveThicknessDots, scale, dpmm), 1); // Option-A geometry (mirrors src/lib/shapeRender.ts): // - Axis-aligned lines map to ^GB and extrude thickness downward @@ -131,8 +131,6 @@ export function LineObject({ // Otherwise dragging a near-horizontal endpoint shows the body locked // to the horizontal band until release, then snaps to the parallelo- // gram — a visible jump the user noticed. - const halfStrokePx = lineStrokeWidth / 2; - // Live positions while handles are being dragged (snapped preview) const [livePt1, setLivePt1] = useState<{ x: number; y: number } | null>(null); @@ -152,6 +150,16 @@ export function LineObject({ const dispX2 = livePt2?.x ?? x2 + dx; const dispY2 = livePt2?.y ?? y2 + dy; + // Mid-drag, an endpoint can be pulled inside the current thickness; the + // onDragEnd commit then snaps thickness down to the new length, which + // would look like a sudden band shrink on release. Cap the visual stroke + // at the live endpoint distance so the band always tracks the t ≤ length + // invariant we commit to. In steady state the data model already + // satisfies this, so the cap is a no-op. + const visualLenPx = Math.hypot(dispX2 - dispX1, dispY2 - dispY1); + const lineStrokeWidth = Math.min(rawStrokePx, visualLenPx); + const halfStrokePx = lineStrokeWidth / 2; + // Half-pixel epsilon: constrainLine's auto-snap commits 45°-step // positions where ddx/ddy land exactly on axis-aligned values, but // float math can leave a tiny residue. <0.5 px collapses to "the diff --git a/src/registry/line.tsx b/src/registry/line.tsx index 3928e471..7f91e1e8 100644 --- a/src/registry/line.tsx +++ b/src/registry/line.tsx @@ -124,11 +124,18 @@ export const line: ObjectTypeDefinition = { length prints `t × t`. - min={p.thickness} - onChange={(length) => onChange({ length })} + min={1} + // Shrinking length below the current thickness would land + // the model in the ^GB promotion regime where t > length + // prints `t × t`; auto-clamp thickness down to match the + // new length, mirroring the endpoint-handle drag. + onChange={(length) => + onChange( + length < p.thickness + ? { length, thickness: length } + : { length }, + ) + } />