diff --git a/components/controls/StrokeControls.qml b/components/controls/StrokeControls.qml index 42e2dce..debcf97 100644 --- a/components/controls/StrokeControls.qml +++ b/components/controls/StrokeControls.qml @@ -14,6 +14,7 @@ ColumnLayout { property string strokeCap: "butt" // "butt", "square", "round" property string strokeAlign: "center" // "center", "inner", "outer" property string strokeOrder: "top" // "top" or "bottom" + property bool strokeScaleWithObject: false signal widthEdited(real newWidth) signal widthCommitted(real newWidth) @@ -21,6 +22,7 @@ ColumnLayout { signal capChanged(string newCap) signal alignChanged(string newAlign) signal orderChanged(string newOrder) + signal scaleWithObjectChanged(bool newValue) readonly property SystemPalette themePalette: Lucent.Themed.palette @@ -279,5 +281,16 @@ ColumnLayout { } } } + + Item { + Layout.fillWidth: true + } + + CheckBox { + text: qsTr("Scale with object") + font.pixelSize: 12 + checked: root.strokeScaleWithObject + onToggled: root.scaleWithObjectChanged(checked) + } } } diff --git a/components/controls/StrokeEditorButton.qml b/components/controls/StrokeEditorButton.qml index 2398524..e9998a7 100644 --- a/components/controls/StrokeEditorButton.qml +++ b/components/controls/StrokeEditorButton.qml @@ -15,6 +15,7 @@ Item { property string strokeCap: "butt" // "butt", "square", "round" property string strokeAlign: "center" // "center", "inner", "outer" property string strokeOrder: "top" // "top" or "bottom" + property bool strokeScaleWithObject: false signal widthEdited(real newWidth) signal widthCommitted(real newWidth) @@ -22,6 +23,7 @@ Item { signal capChanged(string newCap) signal alignChanged(string newAlign) signal orderChanged(string newOrder) + signal scaleWithObjectChanged(bool newValue) signal panelOpened signal panelClosed @@ -102,12 +104,14 @@ Item { strokeCap: root.strokeCap strokeAlign: root.strokeAlign strokeOrder: root.strokeOrder + strokeScaleWithObject: root.strokeScaleWithObject onWidthEdited: newWidth => root.widthEdited(newWidth) onWidthCommitted: newWidth => root.widthCommitted(newWidth) onStyleChanged: newStyle => root.styleChanged(newStyle) onCapChanged: newCap => root.capChanged(newCap) onAlignChanged: newAlign => root.alignChanged(newAlign) onOrderChanged: newOrder => root.orderChanged(newOrder) + onScaleWithObjectChanged: newValue => root.scaleWithObjectChanged(newValue) } } } diff --git a/components/tools/EllipseTool.qml b/components/tools/EllipseTool.qml index ec1c259..89bf028 100644 --- a/components/tools/EllipseTool.qml +++ b/components/tools/EllipseTool.qml @@ -68,7 +68,8 @@ Item { visible: style.strokeVisible, cap: style.strokeCap, align: style.strokeAlign, - order: style.strokeOrder + order: style.strokeOrder, + scaleWithObject: style.strokeScaleWithObject } ] }); @@ -141,7 +142,8 @@ Item { visible: style.strokeVisible, cap: style.strokeCap, align: style.strokeAlign, - order: style.strokeOrder + order: style.strokeOrder, + scaleWithObject: style.strokeScaleWithObject } ] }); diff --git a/components/tools/EllipseToolSettings.qml b/components/tools/EllipseToolSettings.qml index 0fef2ac..af29f82 100644 --- a/components/tools/EllipseToolSettings.qml +++ b/components/tools/EllipseToolSettings.qml @@ -21,6 +21,7 @@ RowLayout { property string _defaultStrokeCap: "butt" property string _defaultStrokeAlign: "center" property string _defaultStrokeOrder: "top" + property bool _defaultStrokeScaleWithObject: false property color _defaultFillColor: Lucent.Themed.defaultFill property real _defaultFillOpacity: 1.0 @@ -97,6 +98,13 @@ RowLayout { } return _defaultStrokeOrder; } + readonly property bool strokeScaleWithObject: { + if (editMode) { + var stroke = _getStroke(); + return stroke ? (stroke.scaleWithObject === true) : _defaultStrokeScaleWithObject; + } + return _defaultStrokeScaleWithObject; + } readonly property color fillColor: { if (editMode) { @@ -130,6 +138,8 @@ RowLayout { _defaultStrokeAlign = value; else if (propName === "strokeOrder") _defaultStrokeOrder = value; + else if (propName === "strokeScaleWithObject") + _defaultStrokeScaleWithObject = value; else if (propName === "fillColor") _defaultFillColor = value; else if (propName === "fillOpacity") @@ -166,6 +176,8 @@ RowLayout { updated.align = value; else if (propName === "strokeOrder") updated.order = value; + else if (propName === "strokeScaleWithObject") + updated.scaleWithObject = value; } newAppearances.push(updated); } @@ -219,12 +231,14 @@ RowLayout { strokeCap: root.strokeCap strokeAlign: root.strokeAlign strokeOrder: root.strokeOrder + strokeScaleWithObject: root.strokeScaleWithObject onWidthEdited: newWidth => root.updateProperty("strokeWidth", newWidth) onWidthCommitted: newWidth => root.updateProperty("strokeWidth", newWidth) onStyleChanged: newStyle => root.updateProperty("strokeVisible", newStyle === "solid") onCapChanged: newCap => root.updateProperty("strokeCap", newCap) onAlignChanged: newAlign => root.updateProperty("strokeAlign", newAlign) onOrderChanged: newOrder => root.updateProperty("strokeOrder", newOrder) + onScaleWithObjectChanged: newValue => root.updateProperty("strokeScaleWithObject", newValue) onPanelOpened: canvasModel.beginTransaction() onPanelClosed: canvasModel.endTransaction() } diff --git a/components/tools/PenTool.qml b/components/tools/PenTool.qml index 0af6ff2..05281db 100644 --- a/components/tools/PenTool.qml +++ b/components/tools/PenTool.qml @@ -89,6 +89,7 @@ Item { var strokeCap = s.strokeCap !== undefined ? s.strokeCap : "butt"; var strokeAlign = s.strokeAlign !== undefined ? s.strokeAlign : "center"; var strokeOrder = s.strokeOrder !== undefined ? s.strokeOrder : "top"; + var strokeScaleWithObject = s.strokeScaleWithObject === true; var fillColor = _colorString(s.fillColor); var fillOpacity = s.fillOpacity !== undefined ? s.fillOpacity : 1.0; @@ -113,7 +114,8 @@ Item { visible: strokeVisible, cap: strokeCap, align: strokeAlign, - order: strokeOrder + order: strokeOrder, + scaleWithObject: strokeScaleWithObject } ] }); @@ -369,6 +371,7 @@ Item { var strokeCap = s.strokeCap !== undefined ? s.strokeCap : "butt"; var strokeAlign = s.strokeAlign !== undefined ? s.strokeAlign : "center"; var strokeOrder = s.strokeOrder !== undefined ? s.strokeOrder : "top"; + var strokeScaleWithObject = s.strokeScaleWithObject === true; var fillColor = tool._colorString(s.fillColor); var fillOpacity = s.fillOpacity !== undefined ? s.fillOpacity : 1.0; @@ -395,7 +398,8 @@ Item { visible: strokeVisible, cap: strokeCap, align: strokeAlign, - order: strokeOrder + order: strokeOrder, + scaleWithObject: strokeScaleWithObject } ] }); diff --git a/components/tools/PenToolSettings.qml b/components/tools/PenToolSettings.qml index 7aeffcc..c1244d1 100644 --- a/components/tools/PenToolSettings.qml +++ b/components/tools/PenToolSettings.qml @@ -21,6 +21,7 @@ RowLayout { property string _defaultStrokeCap: "butt" property string _defaultStrokeAlign: "center" property string _defaultStrokeOrder: "top" + property bool _defaultStrokeScaleWithObject: false property color _defaultFillColor: Lucent.Themed.defaultFill property real _defaultFillOpacity: 0.0 @@ -97,6 +98,13 @@ RowLayout { } return _defaultStrokeOrder; } + readonly property bool strokeScaleWithObject: { + if (editMode) { + var stroke = _getStroke(); + return stroke ? (stroke.scaleWithObject === true) : _defaultStrokeScaleWithObject; + } + return _defaultStrokeScaleWithObject; + } readonly property color fillColor: { if (editMode) { @@ -130,6 +138,8 @@ RowLayout { _defaultStrokeAlign = value; else if (propName === "strokeOrder") _defaultStrokeOrder = value; + else if (propName === "strokeScaleWithObject") + _defaultStrokeScaleWithObject = value; else if (propName === "fillColor") _defaultFillColor = value; else if (propName === "fillOpacity") @@ -166,6 +176,8 @@ RowLayout { updated.align = value; else if (propName === "strokeOrder") updated.order = value; + else if (propName === "strokeScaleWithObject") + updated.scaleWithObject = value; } newAppearances.push(updated); } @@ -219,12 +231,14 @@ RowLayout { strokeCap: root.strokeCap strokeAlign: root.strokeAlign strokeOrder: root.strokeOrder + strokeScaleWithObject: root.strokeScaleWithObject onWidthEdited: newWidth => root.updateProperty("strokeWidth", newWidth) onWidthCommitted: newWidth => root.updateProperty("strokeWidth", newWidth) onStyleChanged: newStyle => root.updateProperty("strokeVisible", newStyle === "solid") onCapChanged: newCap => root.updateProperty("strokeCap", newCap) onAlignChanged: newAlign => root.updateProperty("strokeAlign", newAlign) onOrderChanged: newOrder => root.updateProperty("strokeOrder", newOrder) + onScaleWithObjectChanged: newValue => root.updateProperty("strokeScaleWithObject", newValue) onPanelOpened: canvasModel.beginTransaction() onPanelClosed: canvasModel.endTransaction() } diff --git a/components/tools/RectangleTool.qml b/components/tools/RectangleTool.qml index b8d1e82..36340c2 100644 --- a/components/tools/RectangleTool.qml +++ b/components/tools/RectangleTool.qml @@ -74,7 +74,8 @@ Item { visible: style.strokeVisible, cap: style.strokeCap, align: style.strokeAlign, - order: style.strokeOrder + order: style.strokeOrder, + scaleWithObject: style.strokeScaleWithObject } ] }); @@ -161,7 +162,8 @@ Item { visible: style.strokeVisible, cap: style.strokeCap, align: style.strokeAlign, - order: style.strokeOrder + order: style.strokeOrder, + scaleWithObject: style.strokeScaleWithObject } ] }); diff --git a/components/tools/RectangleToolSettings.qml b/components/tools/RectangleToolSettings.qml index 4b1471a..4d830f1 100644 --- a/components/tools/RectangleToolSettings.qml +++ b/components/tools/RectangleToolSettings.qml @@ -21,6 +21,7 @@ RowLayout { property string _defaultStrokeCap: "butt" property string _defaultStrokeAlign: "center" property string _defaultStrokeOrder: "top" + property bool _defaultStrokeScaleWithObject: false property color _defaultFillColor: Lucent.Themed.defaultFill property real _defaultFillOpacity: 1.0 property real _defaultCornerRadius: 0 @@ -103,6 +104,13 @@ RowLayout { } return _defaultStrokeOrder; } + readonly property bool strokeScaleWithObject: { + if (editMode) { + var stroke = _getStroke(); + return stroke ? (stroke.scaleWithObject === true) : _defaultStrokeScaleWithObject; + } + return _defaultStrokeScaleWithObject; + } readonly property color fillColor: { if (editMode) { @@ -179,6 +187,8 @@ RowLayout { _defaultStrokeAlign = value; else if (propName === "strokeOrder") _defaultStrokeOrder = value; + else if (propName === "strokeScaleWithObject") + _defaultStrokeScaleWithObject = value; else if (propName === "fillColor") _defaultFillColor = value; else if (propName === "fillOpacity") @@ -201,7 +211,7 @@ RowLayout { if (!currentItem) return; - var updatesAppearance = propName === "fillColor" || propName === "fillOpacity" || propName === "strokeWidth" || propName === "strokeColor" || propName === "strokeOpacity" || propName === "strokeVisible" || propName === "strokeCap" || propName === "strokeAlign" || propName === "strokeOrder"; + var updatesAppearance = propName === "fillColor" || propName === "fillOpacity" || propName === "strokeWidth" || propName === "strokeColor" || propName === "strokeOpacity" || propName === "strokeVisible" || propName === "strokeCap" || propName === "strokeAlign" || propName === "strokeOrder" || propName === "strokeScaleWithObject"; var updatesGeometry = propName === "cornerRadius" || propName === "cornerRadiusTL" || propName === "cornerRadiusTR" || propName === "cornerRadiusBR" || propName === "cornerRadiusBL"; var newAppearances = currentItem.appearances; @@ -230,6 +240,8 @@ RowLayout { updated.align = value; else if (propName === "strokeOrder") updated.order = value; + else if (propName === "strokeScaleWithObject") + updated.scaleWithObject = value; } newAppearances.push(updated); } @@ -299,12 +311,14 @@ RowLayout { strokeCap: root.strokeCap strokeAlign: root.strokeAlign strokeOrder: root.strokeOrder + strokeScaleWithObject: root.strokeScaleWithObject onWidthEdited: newWidth => root.updateProperty("strokeWidth", newWidth) onWidthCommitted: newWidth => root.updateProperty("strokeWidth", newWidth) onStyleChanged: newStyle => root.updateProperty("strokeVisible", newStyle === "solid") onCapChanged: newCap => root.updateProperty("strokeCap", newCap) onAlignChanged: newAlign => root.updateProperty("strokeAlign", newAlign) onOrderChanged: newOrder => root.updateProperty("strokeOrder", newOrder) + onScaleWithObjectChanged: newValue => root.updateProperty("strokeScaleWithObject", newValue) onPanelOpened: canvasModel.beginTransaction() onPanelClosed: canvasModel.endTransaction() } diff --git a/components/tools/TwoPointToolHelper.qml b/components/tools/TwoPointToolHelper.qml index 550c574..b59de37 100644 --- a/components/tools/TwoPointToolHelper.qml +++ b/components/tools/TwoPointToolHelper.qml @@ -31,6 +31,7 @@ QtObject { strokeCap: settings ? (settings.strokeCap !== undefined ? settings.strokeCap : "butt") : "butt", strokeAlign: settings ? (settings.strokeAlign !== undefined ? settings.strokeAlign : "center") : "center", strokeOrder: settings ? (settings.strokeOrder !== undefined ? settings.strokeOrder : "top") : "top", + strokeScaleWithObject: settings ? (settings.strokeScaleWithObject === true) : false, fillColor: settings ? settings.fillColor.toString() : "", fillOpacity: settings ? settings.fillOpacity : 1.0 }; diff --git a/src/lucent/appearances.py b/src/lucent/appearances.py index 02e6f2d..51c9905 100644 --- a/src/lucent/appearances.py +++ b/src/lucent/appearances.py @@ -162,6 +162,7 @@ def __init__( cap: str = "butt", align: str = "center", order: str = "top", + scale_with_object: bool = False, ) -> None: super().__init__(visible) self.color = color @@ -170,6 +171,7 @@ def __init__( self.cap = cap if cap in self.VALID_CAPS else "butt" self.align = align if align in self.VALID_ALIGNS else "center" self.order = order if order in self.VALID_ORDERS else "top" + self.scale_with_object = bool(scale_with_object) def render( self, @@ -237,6 +239,7 @@ def to_dict(self) -> Dict[str, Any]: "cap": self.cap, "align": self.align, "order": self.order, + "scaleWithObject": self.scale_with_object, } @staticmethod @@ -250,6 +253,7 @@ def from_dict(data: Dict[str, Any]) -> "Stroke": cap=data.get("cap", "butt"), align=data.get("align", "center"), order=data.get("order", "top"), + scale_with_object=bool(data.get("scaleWithObject", False)), ) def get_sg_color(self) -> Optional[QColor]: diff --git a/src/lucent/item_schema.py b/src/lucent/item_schema.py index 9a12401..f172697 100644 --- a/src/lucent/item_schema.py +++ b/src/lucent/item_schema.py @@ -86,6 +86,7 @@ def _parse_appearances(data: Dict[str, Any]) -> List[Dict[str, Any]]: "cap": "butt", "align": "center", "order": "top", + "scaleWithObject": False, }, ] @@ -121,6 +122,7 @@ def _parse_appearances(data: Dict[str, Any]) -> List[Dict[str, Any]]: "cap": cap, "align": align, "order": order, + "scaleWithObject": bool(a.get("scaleWithObject", False)), } ) return result diff --git a/src/lucent/texture_cache.py b/src/lucent/texture_cache.py index a9e335b..3be6bbb 100644 --- a/src/lucent/texture_cache.py +++ b/src/lucent/texture_cache.py @@ -118,7 +118,19 @@ def _get_item_version(self, item: "CanvasItem") -> int: align = getattr(item.stroke, "align", "center") cap = getattr(item.stroke, "cap", "butt") order = getattr(item.stroke, "order", "top") - version ^= hash((item.stroke.color, item.stroke.width, cap, align, order)) + scale_with_object = getattr(item.stroke, "scale_with_object", False) + version ^= hash( + ( + item.stroke.color, + item.stroke.width, + cap, + align, + order, + scale_with_object, + ) + ) + if not scale_with_object and hasattr(item, "transform"): + version ^= hash((item.transform.scale_x, item.transform.scale_y)) return version @@ -174,6 +186,17 @@ def _rasterize_item( if geometry_bounds.isEmpty(): return None + stroke = getattr(item, "stroke", None) + stroke_ref = None + original_stroke_width = None + if stroke and not getattr(stroke, "scale_with_object", False): + scale_x = getattr(item.transform, "scale_x", 1.0) + scale_y = getattr(item.transform, "scale_y", 1.0) + scale_factor = max(abs(scale_x), abs(scale_y), 1e-6) + original_stroke_width = stroke.width + stroke.width = max(0.0, min(100.0, original_stroke_width / scale_factor)) + stroke_ref = stroke + # Get accurate bounds including stroke effects render_bounds = self._get_render_bounds(item) @@ -201,6 +224,8 @@ def _rasterize_item( item.paint(painter, zoom_level=1.0, offset_x=0.0, offset_y=0.0) finally: item.transform = original_transform + if original_stroke_width is not None and stroke_ref is not None: + stroke_ref.width = original_stroke_width painter.end() diff --git a/tests/test_appearances.py b/tests/test_appearances.py index 26f7662..cd046a3 100644 --- a/tests/test_appearances.py +++ b/tests/test_appearances.py @@ -165,6 +165,7 @@ def test_basic_creation(self): assert stroke.cap == "butt" assert stroke.align == "center" assert stroke.order == "top" + assert stroke.scale_with_object is False def test_creation_with_parameters(self): """Test creating a stroke with custom parameters.""" @@ -207,6 +208,7 @@ def test_to_dict(self): "cap": "butt", "align": "center", "order": "top", + "scaleWithObject": False, } def test_from_dict(self): @@ -223,6 +225,7 @@ def test_from_dict(self): assert stroke.width == 4.0 assert stroke.opacity == 0.6 assert stroke.visible is False + assert stroke.scale_with_object is False def test_from_dict_defaults(self): """Test from_dict uses defaults for missing fields.""" diff --git a/tests/test_item_schema.py b/tests/test_item_schema.py index 9302e04..5dca77d 100644 --- a/tests/test_item_schema.py +++ b/tests/test_item_schema.py @@ -93,6 +93,22 @@ def test_validate_path_clamps_appearances(): assert stroke["opacity"] == 1.0 +def test_validate_path_defaults_stroke_scale_with_object_false(): + data = { + "type": "path", + "geometry": { + "points": [{"x": 1, "y": 2}, {"x": -3, "y": 4}], + "closed": True, + }, + "appearances": [ + {"type": "stroke", "color": "#123456", "width": 2.0, "opacity": 0.5}, + ], + } + out = validate_path(data) + stroke = next(a for a in out["appearances"] if a["type"] == "stroke") + assert stroke["scaleWithObject"] is False + + def test_validate_path_requires_two_points(): with pytest.raises(ItemSchemaError): validate_path(