Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
144 changes: 102 additions & 42 deletions src/renderer/src/components/PlotCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ function scaleHexColor(hex: string, factor: number): string {
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}

const DEFAULT_OUTLINE_COLOR = "#3a6aaa";
const DEFAULT_OUTLINE_COLOR_SELECTED = "#60a0ff";
const DEFAULT_HATCH_COLOR = "#2a5a8a";
const DEFAULT_HATCH_COLOR_SELECTED = "#4a88cc";

function isHexColor(color: string): boolean {
return /^#[0-9a-f]{6}$/i.test(color);
}

function getOutlineColor(baseColor: string, isSelected: boolean): string {
if (baseColor === DEFAULT_OUTLINE_COLOR) {
return isSelected ? DEFAULT_OUTLINE_COLOR_SELECTED : DEFAULT_OUTLINE_COLOR;
}
if (isSelected && isHexColor(baseColor))
return scaleHexColor(baseColor, 1.35);
return baseColor;
}

function getHatchColor(baseColor: string, isSelected: boolean): string {
if (baseColor === DEFAULT_OUTLINE_COLOR) {
return isSelected ? DEFAULT_HATCH_COLOR_SELECTED : DEFAULT_HATCH_COLOR;
}
if (isSelected) return baseColor;
if (isHexColor(baseColor)) return scaleHexColor(baseColor, 0.65);
return baseColor;
}

export function PlotCanvas() {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -1095,17 +1122,17 @@ export function PlotCanvas() {
// every viewport update).
const toolpathCanvasRef = useRef<HTMLCanvasElement>(null);

// Cache of combined Path2D geometry per import, keyed by import ID.
// Invalidated when imp.paths reference changes (immer creates a new array on
// any mutation). Two entries per import: outline strokes + hatch lines.
// Cache of Path2D geometry per import, keyed by import ID.
// Invalidated when imp.paths or imp.layers references change (immer creates
// new arrays on mutation).
const importPath2DCacheRef = useRef(
new Map<
string,
{
pathsRef: SvgImport["paths"];
layersRef: SvgImport["layers"];
outline: Path2D;
hatch: Path2D;
outlineByColor: Array<{ color: string; path: Path2D }>;
hatchByColor: Array<{ color: string; path: Path2D }>;
}
>(),
);
Expand Down Expand Up @@ -1201,7 +1228,7 @@ export function PlotCanvas() {
}
for (const imp of imports) {
if (!imp.visible) continue;
// Build or retrieve combined Path2D objects for this import.
// Build or retrieve color-bucketed Path2D objects for this import.
let impCache = importPath2DCacheRef.current.get(imp.id);
if (
!impCache ||
Expand All @@ -1214,27 +1241,49 @@ export function PlotCanvas() {
: null;
const isLayerVisible = (p: (typeof imp.paths)[number]) =>
!hiddenLayerIds || !p.layer || !hiddenLayerIds.has(p.layer);
const outlineD = imp.paths
.filter(
(p) =>
p.visible && p.outlineVisible !== false && isLayerVisible(p),
)
.map((p) => p.d)
.join(" ");
const hatchD = imp.paths
.filter(
(p) =>
p.visible &&
(p.hatchLines?.length ?? 0) > 0 &&
isLayerVisible(p),
)
.flatMap((p) => p.hatchLines!)
.join(" ");

const layerStrokeById = new Map<string, string>();
for (const l of imp.layers ?? []) {
if (l.sourceStrokeColor)
layerStrokeById.set(l.id, l.sourceStrokeColor);
}

const getPathBaseColor = (
p: (typeof imp.paths)[number],
): string => {
const layerColor = p.layer
? layerStrokeById.get(p.layer)
: undefined;
return layerColor || p.sourceStrokeColor || DEFAULT_OUTLINE_COLOR;
};

const outlineDByColor = new Map<string, string[]>();
const hatchDByColor = new Map<string, string[]>();

for (const p of imp.paths) {
if (!p.visible || !isLayerVisible(p)) continue;
const baseColor = getPathBaseColor(p);
if (p.outlineVisible !== false) {
const arr = outlineDByColor.get(baseColor) ?? [];
arr.push(p.d);
outlineDByColor.set(baseColor, arr);
}
if ((p.hatchLines?.length ?? 0) > 0) {
const arr = hatchDByColor.get(baseColor) ?? [];
arr.push(...(p.hatchLines ?? []));
hatchDByColor.set(baseColor, arr);
}
}

impCache = {
pathsRef: imp.paths,
layersRef: imp.layers,
outline: new Path2D(outlineD),
hatch: new Path2D(hatchD),
outlineByColor: Array.from(outlineDByColor.entries()).map(
([color, ds]) => ({ color, path: new Path2D(ds.join(" ")) }),
),
hatchByColor: Array.from(hatchDByColor.entries()).map(
([color, ds]) => ({ color, path: new Path2D(ds.join(" ")) }),
),
};
importPath2DCacheRef.current.set(imp.id, impCache);
}
Expand Down Expand Up @@ -1275,31 +1324,42 @@ export function PlotCanvas() {
ctx.setLineDash([]);
// Outline paths — use group colour when assigned, otherwise default blue
const groupColor = groupColorMap.get(imp.id);
const outlineColor = groupColor
? isImpSelected
? scaleHexColor(groupColor, 1.35)
: groupColor
: isImpSelected
? "#60a0ff"
: "#3a6aaa";
const hatchColor = groupColor
? isImpSelected
? groupColor
: scaleHexColor(groupColor, 0.65)
: isImpSelected
? "#4a88cc"
: "#2a5a8a";
ctx.strokeStyle = outlineColor;
ctx.lineWidth =
((imp.strokeWidthMM ?? DEFAULT_STROKE_WIDTH_MM) * MM_TO_PX) /
avgImpScale;
ctx.stroke(impCache.outline);
if (groupColor) {
const outlineColor = isImpSelected
? scaleHexColor(groupColor, 1.35)
: groupColor;
ctx.strokeStyle = outlineColor;
for (const b of impCache.outlineByColor) {
ctx.stroke(b.path);
}
} else {
for (const b of impCache.outlineByColor) {
ctx.strokeStyle = getOutlineColor(b.color, isImpSelected);
ctx.stroke(b.path);
}
}

// Hatch fill lines
ctx.strokeStyle = hatchColor;
ctx.lineWidth =
((imp.strokeWidthMM ?? DEFAULT_STROKE_WIDTH_MM) * 0.5 * MM_TO_PX) /
avgImpScale;
ctx.stroke(impCache.hatch);
if (groupColor) {
const hatchColor = isImpSelected
? groupColor
: scaleHexColor(groupColor, 0.65);
ctx.strokeStyle = hatchColor;
for (const b of impCache.hatchByColor) {
ctx.stroke(b.path);
}
} else {
for (const b of impCache.hatchByColor) {
ctx.strokeStyle = getHatchColor(b.color, isImpSelected);
ctx.stroke(b.path);
}
}
ctx.restore();
}
}
Expand Down
36 changes: 36 additions & 0 deletions src/renderer/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,40 @@ function hasVisibleStroke(el: Element): boolean {
return true;
}

function normalizeImportedColor(raw: string): string | undefined {
const c = raw.trim();
if (!c) return undefined;
const lower = c.toLowerCase();
if (
lower === "none" ||
lower === "transparent" ||
lower === "inherit" ||
lower.startsWith("url(")
) {
return undefined;
}
return c;
}

function getEffectiveStrokeColor(el: Element): string | undefined {
const stroke = resolveInheritedProp(el, "stroke");
return normalizeImportedColor(stroke);
}

function getLayerStrokeColor(g: Element): string | undefined {
const own = getEffectiveStrokeColor(g);
if (own) return own;

// In many authoring tools, layer groups do not carry an explicit stroke;
// child paths define it. Use the first drawable descendant stroke as a
// best-effort source color for the logical layer.
const shape = g.querySelector(
"path, rect, circle, ellipse, line, polyline, polygon",
);
if (!shape) return undefined;
return getEffectiveStrokeColor(shape);
}

// ─── SVG length → mm conversion ─────────────────────────────────────────────────
// Handles unit suffixes from the SVG spec; unitless / px → 96 DPI
function parseSvgLengthMM(val: string | null | undefined): number | null {
Expand Down Expand Up @@ -542,6 +576,7 @@ export function Toolbar({
id: g.id || `layer_${i}`,
name: getLayerName(g, i),
visible: !isDisplayNone(g),
sourceStrokeColor: getLayerStrokeColor(g),
}));
// Ensure every layer group has an id so findContainingLayerId can match it.
layerGroupEls.forEach((g, i) => {
Expand Down Expand Up @@ -635,6 +670,7 @@ export function Toolbar({
outlineVisible,
label,
layer: findContainingLayerId(el, layerGroupIds),
sourceStrokeColor: getEffectiveStrokeColor(el),
},
];
});
Expand Down
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface SvgPath {
/** Id of the `SvgLayer` (within the parent `SvgImport.layers`) that this path
* belongs to, or undefined for paths not inside a detected layer group. */
layer?: string;
/** Source stroke colour resolved at import time (including inherited styles). */
sourceStrokeColor?: string;
/** Whether the original shape had a visible fill colour (used to regenerate hatch) */
hasFill?: boolean;
/** Whether the outline should be plotted. False for shapes with no visible stroke.
Expand All @@ -98,6 +100,8 @@ export interface SvgLayer {
name: string;
/** Whether paths in this layer are currently visible on the canvas */
visible: boolean;
/** Source stroke colour resolved from the layer group, if present. */
sourceStrokeColor?: string;
}

/** Default hatch spacing in mm — used on import and as the UI default. */
Expand Down
39 changes: 37 additions & 2 deletions tests/component/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,10 +1120,10 @@ describe("Toolbar", () => {
it("detects Inkscape layer groups via inkscape:groupmode and maps layer ids", async () => {
const svgXml = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="100mm" height="100mm" viewBox="0 0 100 100">
<g id="layer-a" inkscape:groupmode="layer" inkscape:label="Layer A">
<path d="M 0,0 L 50,50" stroke="red" fill="none" />
<path d="M 0,0 L 50,50" stroke="#ff0000" fill="none" />
</g>
<g id="layer-b" inkscape:groupmode="layer" inkscape:label="Layer B">
<path d="M 10,10 L 90,90" stroke="blue" fill="none" />
<path d="M 10,10 L 90,90" stroke="#0000ff" fill="none" />
</g>
</svg>`;
(
Expand Down Expand Up @@ -1152,6 +1152,41 @@ describe("Toolbar", () => {

expect(imp.paths.filter((p) => p.layer === "layer-a")).toHaveLength(1);
expect(imp.paths.filter((p) => p.layer === "layer-b")).toHaveLength(1);

expect(layerA?.sourceStrokeColor).toBe("#ff0000");
expect(layerB?.sourceStrokeColor).toBe("#0000ff");
expect(
imp.paths.find((p) => p.layer === "layer-a")?.sourceStrokeColor,
).toBe("#ff0000");
expect(
imp.paths.find((p) => p.layer === "layer-b")?.sourceStrokeColor,
).toBe("#0000ff");
});

it("captures inherited stroke colors from ancestor groups", async () => {
const svgXml = `<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="100mm" viewBox="0 0 100 100">
<g id="outer" stroke="#112233">
<g id="inner">
<path d="M 0,0 L 50,50" fill="none" />
</g>
</g>
</svg>`;
(
window.terraForge.fs.openImportDialog as ReturnType<typeof vi.fn>
).mockResolvedValue("/inherited-stroke.svg");
(
window.terraForge.fs.readFile as ReturnType<typeof vi.fn>
).mockResolvedValue(svgXml);

render(<Toolbar />);
await userEvent.click(screen.getByText("Import"));
await waitFor(() => {
expect(useCanvasStore.getState().imports.length).toBe(1);
});

const imp = useCanvasStore.getState().imports[0];
expect(imp.paths).toHaveLength(1);
expect(imp.paths[0].sourceStrokeColor).toBe("#112233");
});

it("does not treat plain groups without markers as layers", async () => {
Expand Down
Loading