Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ coverage/

# Test output
tests/fixtures/__diffs__/
tests/fixtures/__shape_diffs__/

# Editor directories and files
.vscode/*
Expand All @@ -51,4 +52,5 @@ tests/fixtures/__diffs__/
*.instructions.*

!tests/fixtures/labelary_images/*.png
!tests/fixtures/labelary_shape_images/*.png
CLAUDE.md
75 changes: 58 additions & 17 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LineObject } from "./LineObject";
import { ImageObject } from "./ImageObject";
import type Konva from "konva";
import { dotsToPx, pxToDots } from "../../lib/coordinates";
import { outlineInset } from "../../lib/shapeGeometry";
import { useColorScheme } from "../../lib/useColorScheme";
import {
objectToDisplay,
Expand Down Expand Up @@ -277,6 +278,17 @@ function KonvaObjectInner({
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
const cornerRadius =
p.rounding * dotsToPx(Math.min(p.width, p.height) / 8, scale, dpmm);
// Option-A geometry (delegated to lib/shapeGeometry.ts so the Konva
// canvas, the @napi-rs pixel-regression renderer, and any future
// consumer share one definition of ZPL ^GB extrusion). Centred
// stroke on the inset rect places the band exactly inside the
// declared bbox; the firmware's clamp-to-solid rule is handled by
// `renderFilled`.
const insetGeom = outlineInset(w, h, strokeWidth, p.filled);
const renderFilled = insetGeom.renderFilled;
const insetCornerRadius = renderFilled
? cornerRadius
: Math.max(0, cornerRadius - strokeWidth / 2);

// Inverted (^LRY) regions print as a knockout. The difference-blend
// body renders print-correctly: on the white label it produces black
Expand All @@ -299,16 +311,19 @@ function KonvaObjectInner({
// and outlined indistinguishable on canvas.
const isReverse = !!p.reverse;
const shapeColor = p.color === "B" ? "#000000" : "#cccccc";
// `renderFilled` includes the firmware clamp-to-solid case, so a
// very-thick outline picks the filled fill/stroke pair instead of
// collapsing into a degenerate inset rect.
const stroke = isReverse
? p.filled
? renderFilled
? "transparent"
: "#ffffff"
: shapeColor;
const fill = isReverse
? p.filled
? renderFilled
? "#ffffff"
: "transparent"
: p.filled
: renderFilled
? shapeColor
: "transparent";
// Wrap body + selection overlay in a draggable Group so both move
Expand All @@ -333,22 +348,25 @@ function KonvaObjectInner({
onDragEnd={handleDragEnd}
>
<Rect
x={0}
y={0}
width={w}
height={h}
x={insetGeom.offset}
y={insetGeom.offset}
width={insetGeom.width}
height={insetGeom.height}
stroke={stroke}
strokeWidth={strokeWidth}
strokeWidth={renderFilled ? 0 : strokeWidth}
strokeScaleEnabled={false}
fill={fill}
cornerRadius={cornerRadius}
cornerRadius={insetCornerRadius}
globalCompositeOperation={isReverse ? "difference" : "source-over"}
/>
{isSelected && (
<SelectionOverlay
width={w}
height={h}
strokeWidth={strokeWidth}
// Constant-thin selection stroke (decoupled from the body
// thickness) so a thick outline box doesn't get a thick
// selection halo. Matches the LineSelectionOutline pattern.
strokeWidth={1.5}
color={colors.selection}
cornerRadius={cornerRadius}
/>
Expand All @@ -363,7 +381,14 @@ function KonvaObjectInner({
const ry = dotsToPx(p.height, scale, dpmm) / 2;
const stroke = p.color === "B" ? "#000000" : "#cccccc";
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
const fill = p.filled
// Option-A geometry — same outlineInset() definition as the box
// path so the firmware's clamp-to-solid rule stays consistent
// across shapes; only the centred-stroke placement differs.
const insetGeom = outlineInset(rx * 2, ry * 2, strokeWidth, p.filled);
const renderFilled = insetGeom.renderFilled;
const insetRx = insetGeom.width / 2;
const insetRy = insetGeom.height / 2;
const fill = renderFilled
? p.color === "B"
? "#000000"
: "#ffffff"
Expand All @@ -373,10 +398,16 @@ function KonvaObjectInner({
id={obj.id}
x={x + rx}
y={y + ry}
radiusX={rx}
radiusY={ry}
radiusX={insetRx}
radiusY={insetRy}
stroke={isSelected ? colors.selection : stroke}
strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth}
strokeWidth={
renderFilled
? 0
: isSelected
? Math.max(strokeWidth, 1.5)
: strokeWidth
}
Comment thread
u8array marked this conversation as resolved.
strokeScaleEnabled={false}
fill={fill}
draggable
Expand All @@ -401,7 +432,11 @@ function KonvaObjectInner({
const r = dotsToPx(p.diameter, scale, dpmm) / 2;
const stroke = p.color === "B" ? "#000000" : "#cccccc";
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
const fill = p.filled
// Option-A geometry — same outlineInset() definition as box/ellipse.
const insetGeom = outlineInset(r * 2, r * 2, strokeWidth, p.filled);
const renderFilled = insetGeom.renderFilled;
const insetR = insetGeom.width / 2;
const fill = renderFilled
? p.color === "B"
? "#000000"
: "#ffffff"
Expand All @@ -411,9 +446,15 @@ function KonvaObjectInner({
id={obj.id}
x={x + r}
y={y + r}
radius={r}
radius={insetR}
stroke={isSelected ? colors.selection : stroke}
strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth}
strokeWidth={
renderFilled
? 0
: isSelected
? Math.max(strokeWidth, 1.5)
: strokeWidth
}
strokeScaleEnabled={false}
fill={fill}
draggable
Expand Down
187 changes: 167 additions & 20 deletions src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { dotsToPx, pxToDots } from "../../lib/coordinates";
import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain";
import { useColorScheme } from "../../lib/useColorScheme";
import { computePointSnap, type SnapRect } from "../../lib/snapGuides";
import { diagonalPolygonPoints } from "../../lib/shapeGeometry";
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";

/** Endpoint-handle visuals — small white square with a thin selection
Expand Down Expand Up @@ -107,7 +108,31 @@ export function LineObject({
: p.color === "B"
? "#000000"
: "#cccccc";
const lineStrokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 1);
// Live thickness while the side handle is being dragged. Falls back to
// the stored prop when no drag is in flight; commits to props on
// dragEnd. Wrapping the rendering width in this state means the band,
// selection outline and handle anchors all track the cursor in real
// time without any one-frame delay on release.
const [liveThicknessDots, setLiveThicknessDots] = useState<number | null>(null);
const effectiveThicknessDots = liveThicknessDots ?? p.thickness;
const lineStrokeWidth = 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
// (horizontal) or rightward (vertical) from (obj.x, obj.y) — the
// visible body is shifted by t/2 along that axis so the band fills
// y..y+t / x..x+t exactly. Handles stay at the band's start corner.
// - Diagonal lines map to ^GD: the conceptual line is the left long
// edge of a parallelogram and thickness extrudes purely in +x. The
// diagonalPolygonPoints helper builds the four vertices.
//
// The axis-aligned / diagonal pick is derived from the *live* display
// endpoints rather than `p.angle` (which only updates on dragEnd).
// 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);
Expand All @@ -127,6 +152,18 @@ export function LineObject({
const dispX2 = livePt2?.x ?? x2 + dx;
const dispY2 = livePt2?.y ?? y2 + dy;

// 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
// pixel grid sees this as axis-aligned" without false-positives.
const ddxDisp = dispX2 - dispX1;
const ddyDisp = dispY2 - dispY1;
const isHorizontal = Math.abs(ddyDisp) < 0.5;
const isVertical = Math.abs(ddxDisp) < 0.5;
const isAxisAligned = isHorizontal || isVertical;
const visualShiftX = isVertical ? halfStrokePx : 0;
const visualShiftY = isHorizontal ? halfStrokePx : 0;

// Shift forces the user-explicit 45°-step constraint; otherwise we use
// Figma-style auto-snap (±5° tolerance to the nearest 45° step).
const resolveMode = (shift: boolean): ConstrainMode =>
Expand Down Expand Up @@ -250,37 +287,93 @@ export function LineObject({
};
}

// Thickness handle anchor — sits on the far long edge of the band:
// bottom edge for horizontal lines, right edge otherwise. The handle's
// perpendicular drag direction is then y for horizontal and x for
// anything else, matching ZPL's ^GB / ^GD extrusion conventions.
const lineCenterX = (dispX1 + dispX2) / 2;
const lineCenterY = (dispY1 + dispY2) / 2;
const thickHandleX =
lineCenterX + (isHorizontal ? 0 : lineStrokeWidth);
const thickHandleY =
lineCenterY + (isHorizontal ? lineStrokeWidth : 0);

return (
<Group>
{/* Visible line — tracks both whole-drag and handle-drag live.
Difference blend keeps the reverse case print-correct: on the
white label it renders black, over darker shapes it inverts
those pixels. Stays in reverse mode even when selected so the
inversion visualisation isn't masked. */}
<KLine
points={[dispX1, dispY1, dispX2, dispY2]}
stroke={strokeColor}
strokeWidth={lineStrokeWidth}
lineCap="butt"
listening={false}
globalCompositeOperation={isReverse ? "difference" : "source-over"}
/>
{isSelected && (
<LineSelectionOutline
x1={dispX1}
y1={dispY1}
x2={dispX2}
y2={dispY2}
bodyStrokeWidth={lineStrokeWidth}
color={colors.selection}
/>
{isAxisAligned ? (
<>
<KLine
points={[
dispX1 + visualShiftX,
dispY1 + visualShiftY,
dispX2 + visualShiftX,
dispY2 + visualShiftY,
]}
stroke={strokeColor}
strokeWidth={lineStrokeWidth}
lineCap="butt"
listening={false}
globalCompositeOperation={isReverse ? "difference" : "source-over"}
/>
{isSelected && (
<LineSelectionOutline
x1={dispX1 + visualShiftX}
y1={dispY1 + visualShiftY}
x2={dispX2 + visualShiftX}
y2={dispY2 + visualShiftY}
bodyStrokeWidth={lineStrokeWidth}
color={colors.selection}
/>
)}
</>
) : (
<>
{/* Diagonal ^GD body — closed filled parallelogram rather than
a centred stroke so the canvas matches Labelary's flat-top /
pointy-side geometry. Reverse uses the same difference blend
as the stroked case. */}
<KLine
points={diagonalPolygonPoints(
dispX1, dispY1, dispX2, dispY2, lineStrokeWidth,
)}
closed
fill={strokeColor}
listening={false}
globalCompositeOperation={isReverse ? "difference" : "source-over"}
/>
{isSelected && (
<KLine
points={diagonalPolygonPoints(
dispX1, dispY1, dispX2, dispY2, lineStrokeWidth,
)}
Comment thread
u8array marked this conversation as resolved.
Outdated
closed
stroke={colors.selection}
strokeWidth={1.5}
strokeScaleEnabled={false}
fill="transparent"
listening={false}
/>
)}
</>
)}
{/* Wide transparent hit area — handles click-to-select and whole-line drag.
id is here (not on the Group) so the Stage snap handler can find this node
via e.target.id() and apply object-snap correctly. */}
via e.target.id() and apply object-snap correctly. The hit area is
shifted along with the visible body so clicks register where the
user sees the line. */}
<KLine
id={obj.id}
points={[x1, y1, x2, y2]}
points={[
x1 + visualShiftX,
y1 + visualShiftY,
x2 + visualShiftX,
y2 + visualShiftY,
]}
stroke="transparent"
strokeWidth={Math.max(lineStrokeWidth, 14)}
draggable
Expand Down Expand Up @@ -436,6 +529,60 @@ export function LineObject({
strokeWidth={1}
listening={false}
/>
{/* Thickness handle — drags perpendicular to the extrusion
axis (y for horizontal, x for everything else). Clamps to
the 1-dot minimum; flip-on-overshoot is deferred. */}
<Rect
x={thickHandleX - HANDLE_HIT_SIZE / 2}
y={thickHandleY - HANDLE_HIT_SIZE / 2}
width={HANDLE_HIT_SIZE}
height={HANDLE_HIT_SIZE}
fill="transparent"
draggable
onDragMove={(e) => {
const cursorX = e.target.x() + HANDLE_HIT_SIZE / 2;
const cursorY = e.target.y() + HANDLE_HIT_SIZE / 2;
const extPx = isHorizontal
? cursorY - lineCenterY
: cursorX - lineCenterX;
const newT = Math.max(
1,
Math.round(pxToDots(extPx, scale, dpmm)),
);
setLiveThicknessDots(newT);
// Pin the Rect to the (possibly-clamped) anchor so
// dragging past the minimum doesn't decouple the handle
// from the band edge.
const newStroke = Math.max(dotsToPx(newT, scale, dpmm), 1);
e.target.position({
x:
lineCenterX +
(isHorizontal ? 0 : newStroke) -
HANDLE_HIT_SIZE / 2,
y:
lineCenterY +
(isHorizontal ? newStroke : 0) -
HANDLE_HIT_SIZE / 2,
});
}}
onDragEnd={() => {
const committed = liveThicknessDots;
setLiveThicknessDots(null);
if (committed !== null && committed !== p.thickness) {
onChange({ props: { thickness: committed } });
}
}}
/>
<Rect
x={thickHandleX - HANDLE_VISIBLE_SIZE / 2}
y={thickHandleY - HANDLE_VISIBLE_SIZE / 2}
width={HANDLE_VISIBLE_SIZE}
height={HANDLE_VISIBLE_SIZE}
fill="white"
stroke={colors.selection}
strokeWidth={1}
listening={false}
/>
</>
)}
</Group>
Expand Down
Loading