From d1d180805d8006f32b0a7052c12a3f88edac2696 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:26:19 -0800 Subject: [PATCH 01/45] i18n: Hindi labels use the interface sense of their English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 85 Hindi rows used an unrelated sense of their English word. Desired read as देसी (local), and the range message dropped or garbled the desired bound; rulers read as शासक (sovereigns), text characters as चरित्र (personality), vector points and printer's marks as अंक (digits), fills as the verb भरता, High Contrast as उच्च न्यायालय (high court), Hide Thumbnails as हिंदुस्तान, Same as समीर and Even as शाम (evening). They now use the catalog's terms: वांछित, रूलर, कैरेक्टर, पॉइंट, मार्क्स, फ़िल्स and the plain words for the rest. The Hindi glossary test rejects each of these senses in every row whose English names the word, and the meaning test pins Desired and both range messages, minimum <= desired <= maximum. --- crates/ui-egui/src/i18n/hi.tsv | 170 +++++++++++++++---------------- crates/ui-egui/src/i18n/tests.rs | 33 +++++- docs/localization-hi.md | 17 +++- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 132 insertions(+), 91 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..81a4b436ee 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -747,8 +747,8 @@ antiAlias Sharp शार्प Change Case प्रकरण बदलें Change Panel Layout पैनल लेआउट बदलें Change Screen Mode (F) स्क्रीन मोड बदलें (F) - Change to Artboard Rulers आर्टबोर्ड शासकों में परिवर्तन - Change to Global Rulers वैश्विक शासकों में परिवर्तन + Change to Artboard Rulers आर्टबोर्ड रूलर्स में परिवर्तन + Change to Global Rulers ग्लोबल रूलर्स में परिवर्तन Change to Left-to-Right Layout लेफ्ट-टू-राइट लेआउट में परिवर्तन Change to Right-to-Left Layout राइट-टू-लेफ्ट लेआउट में परिवर्तन Changed since the last check. आखिरी चेक के बाद से बदल गया। @@ -764,12 +764,12 @@ antiAlias Sharp शार्प Channel 4 चैनल 4 Character कैरेक्टर Character / Paragraph कैरेक्टर / पैराग्राफ - Character Alignment चरित्र संरेखण - Character Color चरित्र रंग - Character Direction चरित्र निर्देशन - Character options चरित्र ऑप्शन्स - Character Rotation चरित्र रोटेशन - Character Set: चरित्र सेट: + Character Alignment कैरेक्टर संरेखण + Character Color कैरेक्टर रंग + Character Direction कैरेक्टर दिशा + Character options कैरेक्टर ऑप्शन्स + Character Rotation कैरेक्टर रोटेशन + Character Set: कैरेक्टर सेट: Character Style कैरेक्टर स्टाइल Character Style Options कैरेक्टर स्टाइल ऑप्शन्स Character Styles कैरेक्टर स्टाइल्स @@ -959,7 +959,7 @@ antiAlias Sharp शार्प Compatible Gradient and Gradient Mesh Printing कंपैटिबल ग्रेडिएंट और ग्रेडिएंट मेश प्रिंटिंग Compiled or not text संकलित या टेक्स्ट नहीं Compiled script: ask its author for the .jsx file कंपाइल की हुई स्क्रिप्ट: इसके लेखक से .jsx फ़ाइल माँगें - Complement संगम + Complement पूरक Complementary पूरक Complementary 2 पूरक 2 Complete पूरा करना @@ -976,18 +976,18 @@ antiAlias Sharp शार्प Compression संपीड़न Compression › Color › Above Ppi संपीड़न › रंग › पीपीआई के ऊपर Compression: संपीड़न: - Concave अवतरण + Concave अवतल Concentric Dividers: सांद्रिक विभाजक: Condition identifier: शर्त पहचानकर्ता: Configure Shape आकार को कॉन्फ़िगर करें Conflict {n} of {count} {count} में से विरोध {n} Connect कनेक्ट - Connect Selected End Points कनेक्ट सिलेक्टेड अंत अंक + Connect Selected End Points सिलेक्टेड एंड पॉइंट्स कनेक्ट करें Consider Open Filled Paths as Closed बंद के रूप में ओपन भरा पाथ पर विचार करें Consolidate All Windows सभी विंडो एक साथ लाएँ - Constrain कंस्ट्रक्शन - Constrain Angle कंस्ट्रक्शन एंगल - Constrain Width and Height Proportions कंस्ट्रक्शन चौड़ाई और ऊंचाई के अनुपात + Constrain सीमित करें + Constrain Angle कंस्ट्रेन एंगल + Constrain Width and Height Proportions चौड़ाई और ऊंचाई का अनुपात बनाए रखें Construction Guides निर्माण गाइड Conté Crayon कॉन्टे क्रेयॉन Conté Crayon… कॉन्टे क्रेयॉन… @@ -1162,7 +1162,7 @@ antiAlias Sharp शार्प Cut Artboard कट आर्टबोर्ड Rename Artboards आर्टबोर्ड्स का नाम बदलें Cut Path कट पाथ - Cut Path at Selected Anchor Points सिलेक्टेड एंकर अंक पर पाथ काटें + Cut Path at Selected Anchor Points सिलेक्टेड एंकर पॉइंट्स पर पाथ काटें Cutout कटआउट Cutout… कटआउट… Cyan सियान @@ -1306,7 +1306,7 @@ antiAlias Sharp शार्प Description: विवरण: Deselect डीसिलेक्ट करें Design… डिजाइन… - Desired देसी + Desired वांछित Destination Folder: गंतव्य फ़ोल्डर: Destination: गंतव्य: Detail डिटेल @@ -1335,7 +1335,7 @@ antiAlias Sharp शार्प Dimensions आयाम Direct Selection प्रत्यक्ष सिलेक्शन Direct Selection Tool डायरेक्ट सिलेक्शन टूल - Direct-select anchor points to edit them. उन्हें संपादित करने के लिए डायरेक्ट-सिलेक्शन एंकर अंक। + Direct-select anchor points to edit them. एंकर पॉइंट्स को संपादित करने के लिए उन्हें डायरेक्ट-सिलेक्ट करें। Direction Balance दिशा संतुलन Direction Handles निर्देशन हैंडल Direction: दिशा: @@ -1663,7 +1663,7 @@ antiAlias Sharp शार्प Envelope #10 लिफाफाफा #10 Envelope Distort एन्वेलप डिस्टॉर्ट Envelope DL लिफ़ाफ़ा DL - Envelope Info लिफाफे की जानकारी + Envelope Info एन्वेलप जानकारी Envelope Mesh एन्वेलप मेश Envelope Options एन्वेलप ऑप्शन्स Envelope Options… एन्वेलप ऑप्शन्स… @@ -1674,7 +1674,7 @@ antiAlias Sharp शार्प Eraser Tool इरेज़र टूल Error त्रुटि Esc Esc - Even शाम + Even बराबर Even Fields सम फ़ील्ड्स Even-Numbered Anchors यहां तक कि संख्या वाले एंकर Event: कार्यक्रम: @@ -1847,7 +1847,7 @@ antiAlias Sharp शार्प Fill new strokes नए स्ट्रोक भरें Fill New Type Objects With Placeholder Text नए टाइप ऑब्जेक्ट्स में प्लेसहोल्डर टेक्स्ट भरें Fill Pattern पैटर्न भरें - Fill Rule नियम भरें + Fill Rule फ़िल रूल Fill with Placeholder Text प्लेसहोल्डर टेक्स्ट से भरें Fill: फ़िल: Fill: click for swatches, Shift-click for the color mixer फ़िल: स्वॉचेस के लिए क्लिक करें, कलर मिक्सर के लिए Shift-क्लिक करें @@ -1855,7 +1855,7 @@ antiAlias Sharp शार्प Filled Open Paths भरा खुला पाथ Filled Square भरा वर्ग Filling in form fields and signing फॉर्म फ़ील्ड में भरना और साइन करना - Fills भरता + Fills फ़िल्स Film & Video फ़िल्म और वीडियो Film Grain… फ़िल्म ग्रेन… Filter फ़िल्टर @@ -1959,7 +1959,7 @@ antiAlias Sharp शार्प fonts not copied: {fonts} फ़ॉन्ट कॉपी नहीं हुए: {fonts} Fonts whose licence doesn't allow embedding are not copied. जिन फ़ॉन्ट का लाइसेंस एम्बेडिंग की अनुमति नहीं देता है, उन्हें कॉपी नहीं किया जाता है। For Fractions: फ्रैक्शन के लिए: - Force सेना + Force बलपूर्वक Forced Line Break जबरन लाइन ब्रेक Foreground Level फ़ोरग्राउंड स्तर Forget Import Matches फॉरगेट आयात मैच @@ -1997,7 +1997,7 @@ antiAlias Sharp शार्प Full Width पूर्ण चौड़ाई Fully Inside पूरी तरह से अंदर Fully Outside पूरी तरह बाहर - Function Key: समारोह कुंजी: + Function Key: फ़ंक्शन की: Fuzziness: फ़ज़ीनेस: Gamma गामा Gamut Check गैमट जाँच @@ -2195,8 +2195,8 @@ antiAlias Sharp शार्प Hide Gradient Annotator ग्रेडिएंट एनोटेटर छिपाएँ Hide Grid ग्रिड छिपाएँ Hide Guides गाइड्स छिपाएँ - Hide Hidden Characters छिपे हुए चरित्र छुपाएं - Hide Live Paint Gaps छुपा लाइव पेंट गैप + Hide Hidden Characters हिडन कैरेक्टर छिपाएँ + Hide Live Paint Gaps लाइव पेंट गैप छिपाएँ Hide Note नोट छिपाएँ Hide Options ऑप्शन्स छिपाएँ Hide Others अन्य छिपाएँ @@ -2208,23 +2208,23 @@ antiAlias Sharp शार्प Hide Template टेम्पलेट छिपाएँ Hide Text Threads टेक्स्ट थ्रेड छुपाएं Hide Thumbnail थंबनेल छिपाएँ - Hide Thumbnails हिंदुस्तान + Hide Thumbnails थंबनेल छिपाएँ Hide Transparency Grid पारदर्शिता ग्रिड छिपाएँ Hide Units इकाइयाँ छिपाएँ Hide VectorCraft VectorCraft छिपाएँ - Hide Video Rulers वीडियो शासक + Hide Video Rulers वीडियो रूलर्स छिपाएँ High उच्च High (300 ppi) उच्च (300 ppi) - High Contrast उच्च न्यायालय - High Contrast 2 उच्च न्यायालय 2 - High Contrast 3 उच्च न्यायालय 3 + High Contrast उच्च कंट्रास्ट + High Contrast 2 उच्च कंट्रास्ट 2 + High Contrast 3 उच्च कंट्रास्ट 3 High Fidelity Photo उच्च निष्ठा फोटो High Performance (discrete) उच्च निष्पादन (डिस्क्रीट) High Quality Print उच्च गुणवत्ता वाले प्रिंट High resolution उच्च रिज़ॉल्यूशन High Resolution उच्च रिज़ॉल्यूशन Highlight हाइलाइट - Highlight Alternate Glyphs हाइलाइट अल्टरनेटर ग्लिफ़ + Highlight Alternate Glyphs अल्टरनेट ग्लिफ़ हाइलाइट करें Highlight anchors on mouse over माउस ओवर पर हाइलाइट एंकर Highlight Area हाइलाइट क्षेत्र Highlight Font Name हाइलाइट फ़ॉन्ट नाम @@ -2233,7 +2233,7 @@ antiAlias Sharp शार्प Highlight Substituted Fonts हाईलाइट सबस्टिट्यूटेड फ़ॉन्ट Highlight Substituted Glyphs बदले हुए ग्लिफ़ हाइलाइट करें Highlight Tracking हाइलाइट ट्रैकिंग - Highlight Variable उच्च प्रकाश परिवर्तनीय + Highlight Variable चर हाइलाइट करें Highlight: हाइलाइट: Hindi हिन्दी History हिस्ट्री @@ -2448,7 +2448,7 @@ antiAlias Sharp शार्प Keep Original मूल रखें Keep Plain Text प्लेन टेक्स्ट रखें Keep selected सिलेक्ट करें - Keep Shared Points Together साझा अंक एक साथ रखें + Keep Shared Points Together साझा पॉइंट्स एक साथ रखें Keep the pasted color as a new swatch with a numbered name. पेस्ट किए गए रंग को क्रमांकित नाम वाले नए स्वॉच के रूप में रखें। Keep this row's colors (exclude) इस पंक्ति के रंग (बाहर) रखें Keep visible surfaces only when expanding विस्तार करते समय केवल दृश्यमान सतहों को रखें @@ -2735,7 +2735,7 @@ antiAlias Sharp शार्प Manage Workspaces… वर्कस्पेस प्रबंधित करें… Manually मैन्युअल Marathi मराठी - Mark as trapped निशानेबाज़ी + Mark as trapped ट्रैप्ड के रूप में चिह्नित करें Mark Older Files as [Converted] When Opened पुरानी फ़ाइलें ओपन करने पर उन्हें [Converted] चिह्नित करें Marker… मार्कर… Marks मार्क्स @@ -2806,7 +2806,7 @@ antiAlias Sharp शार्प Minimize छोटा करें Minimum न्यूनतम Minimum angle न्यूनतम कोण - Minimum must not exceed Desired, nor Desired Maximum. न्यूनतम से अधिक नहीं होना चाहिए, न ही अधिकतम होना चाहिए। + Minimum must not exceed Desired, nor Desired Maximum. न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए। Minimum: न्यूनतम: Minus Back पीछे वाला घटाएँ Minus Front सामने वाला घटाएँ @@ -3010,7 +3010,7 @@ antiAlias Sharp शार्प Next Segment अगला खंड No नहीं No actions. Press ● to record. कोई कार्रवाई नहीं। प्रेस ● रिकॉर्ड करने के लिए। - No alternates for the selected character. सिलेक्टेड चरित्र के लिए कोई ऑप्शन नहीं। + No alternates for the selected character. सिलेक्टेड कैरेक्टर के लिए कोई अल्टरनेट नहीं। No artboards match. कोई आर्टबोर्ड मैच नहीं। No assets कोई संपत्ति नहीं No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. अभी कोई एसेट नहीं है। ऑब्जेक्ट › एक्सपोर्ट के लिए कलेक्ट करें से आर्टवर्क जोड़ें या उसे एसेट एक्सपोर्ट पैनल में ड्रैग करें। @@ -3093,7 +3093,7 @@ antiAlias Sharp शार्प Number of Recent Fonts हाल ही में फ़ॉन्ट की संख्या Number: संख्या: Numbered List क्रमांकित सूची - Numbers Without Units Are Points यूनिट्स के बिना नंबर अंक हैं + Numbers Without Units Are Points यूनिट्स के बिना संख्याएँ पॉइंट में हैं Numerator न्यूमेरेटर Object ऑब्जेक्ट Object Highlighting ऑब्जेक्ट हाइलाइटिंग @@ -3267,7 +3267,7 @@ antiAlias Sharp शार्प Outer Glow… बाहरी ग्लो… Outline रूपरेखा Outline Object रूपरेखा ऑब्जेक्ट - Outline Others अन्य लोग + Outline Others अन्य को आउटलाइन व्यू में दिखाएँ Outline Stroke स्ट्रोक को आउटलाइन में बदलें Outline Text टेक्स्ट को आउटलाइन में बदलें Outlined Strokes रेखांकित स्ट्रोक @@ -3321,7 +3321,7 @@ antiAlias Sharp शार्प Pages पृष्ठ Paint Daubs पेंट डॉब्स Paint Daubs… पेंट डॉब्स… - Paint Fills पेंट भरता + Paint Fills फ़िल्स पेंट करें Paint Opacity: पेंट ओपेसिटी: Paint Stops At: पेंट स्टॉप: Paint Strokes पेंट स्ट्रोक @@ -3526,7 +3526,7 @@ antiAlias Sharp शार्प Pointillize पॉइंटिलाइज़ Pointillize… पॉइंटिलाइज़… Points पॉइंट्स - Points per segment प्रति खंड अंक + Points per segment प्रति सेगमेंट पॉइंट्स Points: पॉइंट्स: Points: clicks add free points पॉइंट्स: क्लिक से अलग-अलग पॉइंट जुड़ते हैं Polar Grid ध्रुवीय ग्रिड @@ -3858,7 +3858,7 @@ antiAlias Sharp शार्प Remove Line Breaks लाइन ब्रेक हटाएँ Remove Paragraph Breaks पैराग्राफ ब्रेक निकालें Remove Plug-in प्लग-इन निकालें - Remove Points, Keep Shape अंक निकालें, आकार रखें + Remove Points, Keep Shape पॉइंट्स हटाएँ, शेप रखें Remove Redundant Points गैरज़रूरी पॉइंट हटाएँ Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें @@ -3963,7 +3963,7 @@ antiAlias Sharp शार्प Edit Arrowheads… एरोहेड्स संपादित करें… Reload Arrowheads एरोहेड्स फिर से लोड करें Reset Rotate View व्यू घुमाएँ रीसेट करें - Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें + Reset Ruler Origin रूलर ओरिजिन रीसेट करें Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें Reset Text Transform टेक्स्ट ट्रांसफॉर्मर को रीसेट करें Reset Text Transform and Scale टेक्स्ट ट्रांसफॉर्म और स्केल को रीसेट करें @@ -4037,7 +4037,7 @@ antiAlias Sharp शार्प RGB: RGB: Ridges per segment: प्रति खंड रिज: Right दाएँ - Right Complement सही पूरक + Right Complement राइट कम्प्लीमेंट Right Diagonal दायाँ विकर्ण Right Grid: राइट ग्रिड: Right in Front फ्रंट में राइट @@ -4096,14 +4096,14 @@ antiAlias Sharp शार्प Ruby रूबी Ruby… रूबी… Rule: नियम: - Ruler Origin शासकीय उत्पत्ति + Ruler Origin रूलर ओरिजिन Rulers रूलर्स Rulers & Grids रूलर्स और ग्रिड Run Length रन लंबाई Run Plug-in रन प्लग-इन Runs code the checker can't read रन कोड चेकर को पढ़ा नहीं जा सकता Russian रूसी - Same समीर + Same समान Same As Source स्रोत के समान Same file एक ही फ़ाइल Same item as a later row बाद में पंक्ति के रूप में समान आइटम @@ -4246,8 +4246,8 @@ antiAlias Sharp शार्प Saved Views सेव किया गया दृश्य Saves the current bars, toolbar and panel layout. वर्तमान बार, टूलबार और पैनल लेआउट को सेव करता है। Saving सेव किया जा रहा है - Saving {} बचत {} - Saving Poster.vectorcraft बचत Poster.vectorcraft + Saving {} {} सेव किया जा रहा है + Saving Poster.vectorcraft Poster.vectorcraft सेव किया जा रहा है Saving recovery data for {} {} के लिए रिकवरी डेटा सेव करना Scale आकार बदलें Scale Artwork with Artboard आर्टबोर्ड के साथ स्केल आर्टवर्क @@ -4311,7 +4311,7 @@ antiAlias Sharp शार्प Select a character with the Type tool to see its alternates. अल्टरनेट ग्लिफ़ देखने के लिए टाइप टूल से कोई कैरेक्टर सिलेक्ट करें। Select a parametric shape to edit it. With none selected, these set the next shape. एडिट करने के लिए पैरामीट्रिक शेप सिलेक्ट करें। कोई शेप सिलेक्ट न होने पर ये सेटिंग्स अगली शेप पर लागू होंगी। Select a text object एक टेक्स्ट ऑब्जेक्ट चुनें - Select a text object to edit its character attributes. अपने चरित्र विशेषताओं को संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। + Select a text object to edit its character attributes. कैरेक्टर एट्रिब्यूट्स संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select a text object to edit its paragraph attributes. अपनी पैराग्राफ विशेषताओं को संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select a text object to set its tab stops. अपने टैब स्टॉप सेट करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select All सभी सिलेक्ट करें @@ -4340,7 +4340,7 @@ antiAlias Sharp शार्प Select by Link Status… लिंक स्थिति द्वारा सिलेक्ट करें… Select Data Set डेटा सेट सिलेक्ट करें Select Embedded एम्बेडेड सिलेक्ट करें - Select Fills भरता सिलेक्ट करें + Select Fills फ़िल्स सिलेक्ट करें Select Freeform Point फ़्रीफ़ॉर्म पॉइंट सिलेक्ट करें Select Gradient Stop ग्रेडिएंट स्टॉप चुनें Select Guides गाइड्स सिलेक्ट करें @@ -4587,7 +4587,7 @@ antiAlias Sharp शार्प Show Tool Tips टूल युक्तियाँ दिखाएँ Show Toolbar शो टूलबार Show Transparency Grid पारदर्शिता ग्रिड दिखाएँ - Show Video Rulers शो वीडियो शासक + Show Video Rulers वीडियो रूलर्स दिखाएँ Show Video Safe Areas वीडियो सुरक्षित क्षेत्र दिखाएं Show Vivid/Muted चमकीले/मंद दिखाएँ Show Warm/Cool शो वार्म / कूल @@ -5032,7 +5032,7 @@ antiAlias Sharp शार्प The Print Tiling tool placed the pages. प्रिंट टाइलिंग टूल ने पृष्ठों को रखा। The samples show this brush on a blue stroke. नमूने इस ब्रश को ब्लू स्ट्रोक पर दिखाते हैं। The search stopped at its limit: search a smaller folder. सर्च अपनी लिमिट तक पहुँच गई: छोटे फ़ोल्डर में सर्च करें। - The selected character's font is not available. सिलेक्टेड चरित्र का फ़ॉन्ट उपलब्ध नहीं है। + The selected character's font is not available. सिलेक्टेड कैरेक्टर का फ़ॉन्ट उपलब्ध नहीं है। The selected objects have no CSS of their own (see below). सिलेक्टेड ऑब्जेक्ट का अपना CSS नहीं है (नीचे देखें)। The system's settings of this printer इस प्रिंटर की प्रणाली की सेटिंग These fonts aren't available. Their text shows in another font. ये फ़ॉन्ट उपलब्ध नहीं हैं। इनका टेक्स्ट दूसरे फ़ॉन्ट में दिखता है। @@ -5066,7 +5066,7 @@ antiAlias Sharp शार्प Tile Edge Color टाइल एज रंग Tile Edge Color… टाइल एज रंग… Tile Full Pages टाइल पूर्ण पृष्ठ - Tile Imageable Areas टाइल कल्पनाशील क्षेत्र + Tile Imageable Areas इमेजेबल क्षेत्र टाइल करें Tile Range: टाइल रेंज: Tile Size टाइल साइज़ Tile Type: टाइल प्रकार: @@ -5175,9 +5175,9 @@ antiAlias Sharp शार्प Transverse (the page a quarter turn on the paper) ट्रांसवर्स (पेपर पर एक चौथाई मोड़ पृष्ठ) Trap ट्रैप Trap… ट्रैप… - Traps with Process Color प्रक्रिया रंग के साथ जाल - Triad त्रिदाद - Triad 2 त्रिद 2 + Traps with Process Color प्रोसेस रंग के साथ ट्रैप + Triad त्रिक + Triad 2 त्रिक 2 Triad 3 त्रिक 3 Triangle त्रिभुज Triangle (open) त्रिभुज (ओपन) @@ -5185,7 +5185,7 @@ antiAlias Sharp शार्प Trim ट्रिम करें Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. अपीयरेंस एक्सपैंड करने पर ढकी हुई ज्यामिति ट्रिम करें। लाइव प्रीव्यू पूरी सतह रखता है। ट्रांसपेरेंट पेंट और विज़िबिलिटी लिमिट से बड़ी सतहों के सभी फेस बने रहते हैं। Trim mark weight: ट्रिम मार्क वजन: - Trim marks ट्रिम अंक + Trim marks ट्रिम मार्क्स Trim View ट्रिम देखें True Colors असली रंग True colour needs version 2004 or later: the nearest of 256 colours is written. ट्रू कलर के लिए संस्करण 2004 या बाद का चाहिए: अभी 256 रंगों में सबसे निकट वाला रंग लिखा जाता है। @@ -5412,7 +5412,7 @@ antiAlias Sharp शार्प Video 1920×1080 1920 × 1080 वीडियो Video 3840×2160 वीडियो 3840 × 2160 Video Banner 2560×1440 वीडियो बैनर 2560 × 1440 - Video Ruler Pixel Aspect Ratio: वीडियो शासक पिक्सेल दृष्टि अनुपात: + Video Ruler Pixel Aspect Ratio: वीडियो रूलर पिक्सेल आस्पेक्ट रेशियो: Fade Region Outside Artboard आर्टबोर्ड के बाहर का क्षेत्र धुंधला करें Update While Dragging खींचते समय अपडेट करें Video Thumbnail 1280×720 वीडियो थंबनेल 1280 × 720 @@ -5437,7 +5437,7 @@ antiAlias Sharp शार्प Warp Options वार्प ऑप्शन्स Warp Tool वार्प टूल Watch 184×224 घड़ी 184 × 224 - Watch 198×242 देखें 198 × 242 + Watch 198×242 घड़ी 198 × 242 Water Paper वॉटर पेपर Water Paper… वॉटर पेपर… Watercolor वॉटरकलर @@ -5930,7 +5930,7 @@ antiAlias Sharp शार्प @msg `{name}` is no recovery entry name `{name}` कोई वसूली प्रविष्टि नाम नहीं है @msg `{name}` isn't a colour swatch `{name}` रंग स्वॉच नहीं है @msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` अपने ही प्रकार का पेंट लेता है (रंग, ग्रेडिएंट या पैटर्न) -@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: न्यूनतम इच्छा से अधिक नहीं होना चाहिए, न ही इच्छा अधिकतम +@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए @msg `{other}` doesn't trace an image `{other}` एक इमेज का पता नहीं लगा @msg `{path}` must be a bool or an object of bools `{path}` एक bool होना चाहिए या एक ऑब्जेक्ट होना चाहिए। @msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` एक रंग नहीं है (#rrggbb या एक पूर्व निर्धारित नाम) @@ -6285,7 +6285,7 @@ antiAlias Sharp शार्प @msg a wraparound group is inside a compound path एक रैपराउंड समूह एक मिश्रित पाथ के अंदर है @msg action is required action आवश्यक है @msg add a format to export निर्यात करने के लिए एक फ़ॉर्मैट जोड़ें -@msg Add Points at Crossings: a path went missing क्रॉसिंग्स में अंक जोड़ें: एक पाथ गायब हो गया +@msg Add Points at Crossings: a path went missing क्रॉसिंग पर पॉइंट जोड़ें: एक पाथ गायब हो गया @msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-संगत, डिफ़ॉल्ट) या "7" (पुरानी .ai, संस्करण 7) @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 विरासत .ai फ़ाइलों को लिखते हैं, .ait टेम्पलेट नहीं @msg All colors are within the CMYK gamut सभी रंग CMYK गामट के भीतर हैं @@ -6473,7 +6473,7 @@ antiAlias Sharp शार्प @msg choose at least one status कम से कम एक स्थिति चुनें @msg Choose original artwork, not a dimension's generated paths मूल आर्टवर्क चुनें, एक आयाम उत्पन्न पाथ नहीं @msg choose the font files to add जोड़ने के लिए फ़ॉन्ट फ़ाइलों को चुनें -@msg Choose two different points दो अलग-अलग अंक चुनें +@msg Choose two different points दो अलग-अलग पॉइंट्स चुनें @msg Choose two non-parallel straight segments दो गैर-parallel सीधे खंड चुनें @msg Choose two nonzero angle arms दो nonzero कोण हथियार चुनें @msg Click in type with a Type tool to split it इसे विभाजित करने के लिए टाइप टूल के साथ टाइप करें @@ -6665,7 +6665,7 @@ antiAlias Sharp शार्प @msg data must be an object of key: value, not {v} data: डेटा कुंजी का एक ऑब्जेक्ट होना चाहिए: मान, नहीं {v} @msg Data Recovery can't keep copies in {path}: {e} डेटा रिकवरी {path} में प्रतियां नहीं रख सकती: {e} @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) डेटा रिकवरी की प्रतिलिपियाँ रखने के लिए कहीं नहीं है: निर्धारित प्राथमिकता → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) -@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक नियंत्रण चरित्र होता है, जो एक परिवर्तनीय लाइब्रेरी पकड़ नहीं सकता है +@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक कंट्रोल कैरेक्टर है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता @msg data set `{name}` holds a value for `{var}`, which the library doesn't declare डेटा सेट `{name}` `{var}` के लिए एक मान रखता है, जिसे लाइब्रेरी घोषित नहीं करती है @msg data set `{name}` holds two values for `{var}` डेटा सेट `{name}` में `{var}` के लिए दो मान हैं। @msg data set `{name}` is declared twice डेटा सेट `{name}` को दो बार घोषित किया गया है @@ -6683,7 +6683,7 @@ antiAlias Sharp शार्प @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @msg Dimension point is outside the canvas आयाम बिंदु कैनवास के बाहर है @msg Dimension size is outside its supported range आयाम आकार अपनी समर्थित सीमा से बाहर है -@msg direct-select anchor points of a path एक पाथ के प्रत्यक्ष सिलेक्शन एंकर अंक +@msg direct-select anchor points of a path किसी पाथ के एंकर पॉइंट्स डायरेक्ट-सिलेक्ट करें @msg direct-select anchor points to cut at (not the end points of open paths) डायरेक्ट-सिलेक्शन एंकर पॉइंट्स को कट करने के लिए ( ओपन पाथ के अंतिम बिंदु नहीं) @msg direct-select the anchor points to remove हटाने के लिए एंकर पॉइंट्स सिलेक्ट करें @msg distance must be > 0 distance: दूरी होना चाहिए > 0 @@ -6738,7 +6738,7 @@ antiAlias Sharp शार्प @msg exit isolation mode first पहले अलगाव मोड से बाहर निकलें @msg Expand Appearance: the selection has a basic appearance उपस्थिति का विस्तार: सिलेक्शन में एक बुनियादी उपस्थिति है @msg Expand: no brush strokes selected विस्तार: सिलेक्टेड कोई ब्रश स्ट्रोक नहीं -@msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं +@msg Expand: no gradient fills एक्सपैंड: कोई ग्रेडिएंट फ़िल नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है @msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} @@ -6871,7 +6871,7 @@ antiAlias Sharp शार्प @msg Global Edit is off ग्लोबल एडिट बंद है @msg Global Edit works on paths, compound paths, symbol instances and groups of them ग्लोबल एडिट पाथ, मिश्रित पाथ, सिंबल उदाहरणों और उनके समूहों पर काम करता है @msg global must be true or false global को true या false होना चाहिए -@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} चरित्र की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} कैरेक्टर की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। @msg glyph {g} is not an alternate of {ch} in {_1} {_2} Glyph {g} {_1} {_2} में {ch} का एक वैकल्पिक नहीं है। @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @msg gradient must be "objects" or "mesh" gradient: ग्रेडिएंट "objects" या "mesh" होना चाहिए @@ -7127,8 +7127,8 @@ antiAlias Sharp शार्प @msg missing point लापता बिंदु @msg missing point [x,y] लापता बिंदु [x,y] @msg missing point `{key}` ([x, y]) लापता बिंदु `{key}` ([x, y]) -@msg missing points लापता अंक -@msg missing points [[x,y]…] लापता अंक [[x,y]...] +@msg missing points पॉइंट्स नहीं दिए गए +@msg missing points [[x,y]…] पॉइंट्स नहीं दिए गए [[x,y]…] @msg missing segment लापता खंड @msg missing stops लापता ठहराव @msg missing subpaths लापता subpaths @@ -7165,7 +7165,7 @@ antiAlias Sharp शार्प @msg need at least one anchor कम से कम एक एंकर की जरूरत है @msg need at least one point कम से कम एक बिंदु की जरूरत है @msg need at least two points कम से कम दो बिंदुओं की आवश्यकता -@msg needs points जरूरत अंक +@msg needs points पॉइंट्स चाहिए @msg New Window isn't available yet: a document opens in one window नई विंडो अभी तक उपलब्ध नहीं है: एक डॉक्यूमेंट एक विंडो में खुलता है @msg nineSlice must be true or false nineSlice को true या false होना चाहिए @msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides को [left, top, right, bottom] होना चाहिए, हर मान -16..16, और left + right < 1 तथा top + bottom < 1 @@ -7174,7 +7174,7 @@ antiAlias Sharp शार्प @msg no active data set कोई सक्रिय डेटा सेट नहीं @msg no active document कोई सक्रिय डॉक्यूमेंट @msg no active perspective plane कोई सक्रिय पर्सपेक्टिव विमान नहीं -@msg no anchor points selected कोई एंकर अंक नहीं चुना गया +@msg no anchor points selected कोई एंकर पॉइंट सिलेक्ट नहीं है @msg no app to open files here यहां फ़ाइलों को ओपन करने के लिए कोई ऐप नहीं है @msg no appearance item {index} कोई उपस्थिति आइटम {index} @msg no artboard कोई आर्टबोर्ड नहीं @@ -7199,7 +7199,7 @@ antiAlias Sharp शार्प @msg no brush selected कोई ब्रश नहीं चुना गया @msg no catalog कोई सूची नहीं @msg no changes since the last save आखिरी सेव के बाद से कोई बदलाव नहीं -@msg no character at byte {start} byte {start} पर कोई चरित्र नहीं +@msg no character at byte {start} byte {start} पर कोई कैरेक्टर नहीं @msg no character style `{name}` कोई कैरेक्टर स्टाइल `{name}` @msg no clock to keep a heartbeat by हार्टबीट का समय रखने के लिए कोई घड़ी नहीं @msg no color group `{name}` in the document (see swatch.list) डॉक्यूमेंट में कोई रंग समूह `{name}` (देखें swatch.list) @@ -7622,12 +7622,12 @@ antiAlias Sharp शार्प @msg plug-in trapped: {0} प्लग-इन में ट्रैप हुआ: {0} @msg Plug-ins can't be loaded from {path}: {e} प्लग-इन {path} से लोड नहीं किया जा सकता: {e} @msg point is outside the mesh बिंदु मेश के बाहर है -@msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ अंक [t, left, right] होना चाहिए -@msg points must be a non-empty [[x,y,pressure?]…] list points: अंक एक गैर-खाली होना चाहिए [[x,y, दबाव?]...] सूची -@msg points must be a non-empty list of finite [x,y] points: अंक परिमित की एक गैर-खाली सूची होना चाहिए [x,y] -@msg points must be an array or null points: अंक एक सारणी या null होना चाहिए -@msg points must be finite points: अंक परिमित होना चाहिए -@msg points must be finite [x,y] pairs points: अंक परिमित होना चाहिए [x,y] जोड़े +@msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ पॉइंट्स [t, left, right] होने चाहिए +@msg points must be a non-empty [[x,y,pressure?]…] list points: पॉइंट्स एक गैर-खाली होना चाहिए [[x,y, दबाव?]...] सूची +@msg points must be a non-empty list of finite [x,y] points: पॉइंट्स परिमित की एक गैर-खाली सूची होना चाहिए [x,y] +@msg points must be an array or null points: पॉइंट्स एक सारणी या null होना चाहिए +@msg points must be finite points: पॉइंट्स परिमित होने चाहिए +@msg points must be finite [x,y] pairs points: पॉइंट्स परिमित होने चाहिए [x,y] जोड़े @msg points must contain 1–4096 coordinates points में 1–4096 निर्देशांक होने चाहिए @msg position must be normal, superscript or subscript position: स्थिति सामान्य, सुपरस्क्रिप्ट या सबस्क्रिप्ट होना चाहिए @msg position must be topLeft, topRight, bottomLeft or bottomRight position को topLeft, topRight, bottomLeft या bottomRight होना चाहिए @@ -7701,7 +7701,7 @@ antiAlias Sharp शार्प @msg Saved to User Defined: {_1} उपयोगकर्ता परिभाषित करने के लिए सेव किया गया: {_1} @msg savedPreset: {e} savedPreset: {e} @msg Saving सेव किया जा रहा है -@msg Saving {_1} बचत {_1} +@msg Saving {_1} {_1} सेव किया जा रहा है @msg Saving recovery data for {_1} {_1} के लिए रिकवरी डेटा सेव करना @msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) पैमाने `{s}`: एक कारक जैसे 2x, चौड़ाई (100w), ऊंचाई (100h) या एक रिज़ॉल्यूशन (72ppi) @msg scale must be a number above 0, at most 64 scale: पैमाने 0 से अधिक होना चाहिए, अधिकांश 64 में @@ -7792,7 +7792,7 @@ antiAlias Sharp शार्प @msg Select only dimensions केवल आयाम सिलेक्ट करें @msg select open paths to close बंद करने के लिए खुले रास्ते सिलेक्ट करें @msg select paths पाथ चुनें -@msg select paths or anchor points पाथ या एंकर अंक चुनें +@msg select paths or anchor points पाथ या एंकर पॉइंट्स चुनें @msg select paths or compound paths पाथ या यौगिक पाथ सिलेक्ट करें @msg select paths to join शामिल होने के लिए पाथ सिलेक्ट करें @msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें @@ -7800,7 +7800,7 @@ antiAlias Sharp शार्प @msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें @msg Select point type पॉइंट टाइप सिलेक्ट करें @msg select rows or objects to collect इकट्ठा करने के लिए पंक्तियों या ऑब्जेक्ट्स सिलेक्ट करें -@msg select segments, or paths, to add points to खंडों, या पाथ का सिलेक्ट करने के लिए, अंक जोड़ने के लिए +@msg select segments, or paths, to add points to पॉइंट्स जोड़ने के लिए सेगमेंट या पाथ सिलेक्ट करें @msg select some type first पहले कुछ टाइप सिलेक्ट करें @msg select something to export निर्यात करने के लिए कुछ का सिलेक्शन @msg select something to export (or turn off selectedOnly) एक्सपोर्ट करने के लिए कुछ चुनें (या selectedOnly बंद करें) @@ -7984,7 +7984,7 @@ antiAlias Sharp शार्प @msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size आर्टबोर्ड कैनवास पर फिट नहीं हैं: कम आर्टबोर्ड, कम रिक्ति या छोटे आकार @msg the background worker stopped पृष्ठभूमि कार्यकर्ता बंद कर दिया @msg the blend has no spine मिश्रण में कोई रीढ़ नहीं है -@msg the character's size can't be measured चरित्र का आकार मापा नहीं जा सकता है +@msg the character's size can't be measured कैरेक्टर का आकार मापा नहीं जा सकता है @msg the CMYK image couldn't be written CMYK इमेज को नहीं लिखा जा सकता @msg the Color Picker is not open रंग पिकर खुला नहीं है @msg the compound shape is too complex to expand मिश्रित आकार का विस्तार करने के लिए बहुत जटिल है @@ -8032,12 +8032,12 @@ antiAlias Sharp शार्प @msg the file was updated or encrypted already फ़ाइल को पहले से ही अद्यतन या एन्क्रिप्ट किया गया था @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं -@msg the file's preview header points outside the file फ़ाइल के प्रीव्यू शीर्षलेख फ़ाइल के बाहर अंक +@msg the file's preview header points outside the file फ़ाइल का प्रीव्यू हेडर फ़ाइल के बाहर की जगह बताता है @msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल के परिवर्तनीय बंधन सभी नहीं पढ़े गए थे (बाइंडिंग्स स्किप: {_1}): वे ऑब्जेक्ट अपने चर के लिए बाध्य नहीं हैं @msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल के परिवर्तनीय लाइब्रेरी को पढ़ा नहीं जा सकता: चर पैनल इसे सूचीबद्ध नहीं करता है @msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की परिवर्तनीय लाइब्रेरी आंशिक रूप से पढ़ी गई थी (प्रवेशियों को छोड़ दिया: {_1}): वेरिएबल्स पैनल बाकी हिस्सों को सूचीबद्ध करता है @msg the files come to more than 1 GB: add fewer at a time फ़ाइलों को 1 GB से अधिक करने के लिए आते हैं: एक समय में कम जोड़ें -@msg the first character of a line has no character before it to kern: it moves only up and down (dy) एक पंक्ति के पहले चरित्र में बर्न से पहले कोई चरित्र नहीं है: यह केवल ऊपर और नीचे चला जाता है (डी) +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) किसी लाइन के पहले कैरेक्टर से पहले कर्न करने के लिए कोई कैरेक्टर नहीं है: यह केवल ऊपर और नीचे जाता है (dy) @msg the font {_1} is not available फ़ॉन्ट {_1} उपलब्ध नहीं है @msg the format has no file extension फ़ॉर्मैट में कोई फ़ाइल एक्सटेंशन नहीं है @msg the graphic can't be read: {e} ग्राफिक पढ़ा नहीं जा सकता: {e} @@ -8224,7 +8224,7 @@ antiAlias Sharp शार्प @msg the variable library's document type declaration holds something other than entities of plain text वेरिएबल लाइब्रेरी के डॉक्यूमेंट प्रकार की घोषणा सादे टेक्स्ट की संस्थाओं के अलावा कुछ अन्य रखती है @msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी की संस्थाएं {MAX_LIBRARY_BYTES} बाइट्स से अधिक विस्तार करती हैं @msg the window has no dock विंडो में कोई डॉक नहीं है -@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot विंडो का {what} ({points} अंक × {scale} डीपीआई) एक स्क्रीनशॉट का आकार नहीं दे सकता +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot विंडो का {what} ({points} पॉइंट × {scale} DPI) एक स्क्रीनशॉट का आकार नहीं दे सकता @msg the WMF header is damaged WMF हेडर क्षतिग्रस्त है @msg the Word document has more than {_1} MB of formatting properties, too many to place वर्ड डॉक्यूमेंट में स्वरूपण प्रॉपर्टीज़ के {_1} MB से अधिक है, कई जगहों पर @msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word डॉक्यूमेंट में गुणों के {MAX_PAGES} से अधिक पेज हैं, रखने के लिए बहुत अधिक @@ -8323,8 +8323,8 @@ antiAlias Sharp शार्प @msg unknown brush type `{o}` अज्ञात ब्रश प्रकार `{o}` @msg unknown category `{c}` अज्ञात श्रेणी `{c}` @msg unknown category `{c}` ({_1}) अज्ञात श्रेणी `{c}` ({_1}) -@msg unknown character `{c}` अज्ञात चरित्र `{c}` -@msg unknown character style number field अज्ञात चरित्र स्टाइल संख्या क्षेत्र +@msg unknown character `{c}` अज्ञात कैरेक्टर `{c}` +@msg unknown character style number field अज्ञात कैरेक्टर स्टाइल संख्या फ़ील्ड @msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}` @msg unknown command `{0}` अज्ञात आदेश `{0}` @msg unknown command `{0}`; did you mean `{1}`? अज्ञात आदेश `{0}`; क्या आपको `{1}` का मतलब है? diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index d7f2c8a2ef..2aaaf8f445 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -35,6 +35,9 @@ fn hindi_resolves_locales_and_uses_zero_one_plurals() { /// Every Hindi row uses the terms `docs/localization-hi.md` sets for what its English names: /// सेव for saving, स्वॉच for a swatch, क्रॉप for cropping, रिज़ॉल्यूशन for a resolution and पाथ for /// a path, never पाठ (a reading), and a number of points is a number (संख्या) in points (पॉइंट). +/// Rows also never use a word's unrelated sense: a ruler is not शासक (a sovereign), a text character +/// is not चरित्र (a personality), a vector point or a printer's mark is not अंक (a digit), fills are +/// not भरता (the verb), Desired is not देसी (local), Even is not शाम (evening), and so on. #[test] fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { let hi = Lang::from_code("hi").expect("hi registered"); @@ -43,15 +46,35 @@ fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { for (_, src, translation) in &entries { let english = src.to_lowercase(); let names = |words: &[&str]| english.split(|c: char| !c.is_ascii_alphabetic()).any(|w| words.contains(&w)); - let banned: [(bool, &[&str]); 6] = [ - (names(&["save", "saves", "saved", "saving"]), &["बचा", "सहेज"]), + let banned: &[(bool, &[&str])] = &[ + (names(&["save", "saves", "saved", "saving"]), &["बचा", "बचत", "सहेज"]), (names(&["swatch", "swatches"]), &["घड़ी"]), (names(&["crop"]), &["फसल"]), (names(&["resolution", "resolutions"]), &["संकल्प"]), (names(&["path", "paths"]), &["पथ"]), (true, &["पाठ", "कई अंक"]), + (names(&["ruler", "rulers"]), &["शासक"]), + (names(&["character", "characters"]), &["चरित्र"]), + (names(&["point", "points", "mark", "marks"]), &["अंक"]), + (names(&["fill", "fills"]), &["भरता"]), + (names(&["desired"]), &["देसी", "इच्छा"]), + (names(&["even"]), &["शाम"]), + (names(&["force", "forced"]), &["सेना"]), + (names(&["same"]), &["समीर"]), + (names(&["thumbnail", "thumbnails"]), &["हिंदुस्तान"]), + (names(&["contrast"]), &["न्यायालय"]), + (names(&["function"]), &["समारोह"]), + (names(&["kern"]), &["बर्न"]), + (names(&["imageable"]), &["कल्पनाशील"]), + (names(&["constrain"]), &["कंस्ट्रक्शन"]), + (names(&["complement"]), &["संगम"]), + (names(&["concave"]), &["अवतरण"]), + (names(&["triad"]), &["त्रिद"]), + (names(&["trapped", "traps"]), &["निशानेबाज़", "जाल"]), + (names(&["alternate", "alternates"]), &["अल्टरनेटर"]), + (names(&["highlight"]), &["उच्च प्रकाश"]), ]; - for (applies, words) in banned { + for &(applies, words) in banned { if let Some(word) = words.iter().find(|w| applies && translation.contains(**w)) { wrong.push(format!("{word} in {src:?} -> {translation:?}")); } @@ -83,6 +106,10 @@ fn hindi_rows_keep_the_meaning_of_their_english() { assert!(all.contains("3 आर्टबोर्ड में से 1") && all.contains("सभी आर्टबोर्ड रखने के लिए"), "{all}"); let left_out = message(hi, "the file was read up to an error (bad xref): what follows it was left out"); assert!(left_out.contains("उसके बाद का हिस्सा छोड़ दिया गया"), "{left_out}"); + assert_eq!(tr(hi, "Desired"), "वांछित"); + let range = "न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए"; + assert_eq!(tr(hi, "Minimum must not exceed Desired, nor Desired Maximum."), format!("{range}।")); + assert_eq!(message(hi, "`wordSpacing`: Minimum must not exceed Desired, nor Desired Maximum"), format!("`wordSpacing`: {range}")); } /// Locale tags name their language by region or script (in either case, with an encoding or diff --git a/docs/localization-hi.md b/docs/localization-hi.md index d8dbcaf145..2766a583f4 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -32,6 +32,13 @@ every clause such as "all" or "what follows it". Type as a text object is टा in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms and the point unit in every row. +Translate a label by its sense in the interface, not by its first dictionary sense. A ruler is +रूलर, never शासक (a sovereign); a text character is कैरेक्टर, never चरित्र (a personality); a +vector point is पॉइंट and a printer's mark is मार्क, never अंक (a digit); fills are फ़िल्स, never +भरता (the verb). In a minimum, desired and maximum range, Desired is वांछित, never देसी (local), +and a range message keeps both comparisons: minimum ≤ desired ≤ maximum. The same test rejects +these senses, and others found in the catalog, in every row whose English names the word. + ## Terminology | English | Hindi | @@ -46,7 +53,10 @@ and the point unit in every row. | Artwork | आर्टवर्क | | Path / Anchor point / Handle | पाथ / एंकर पॉइंट / हैंडल | | File path on disk | फ़ाइल पाथ | -| Fill / Stroke | फ़िल / स्ट्रोक | +| Fill / Fills / Stroke | फ़िल / फ़िल्स / स्ट्रोक | +| Point (vector) / Printer's marks | पॉइंट / मार्क्स | +| Ruler / Ruler origin | रूलर / रूलर ओरिजिन | +| Minimum / Desired / Maximum | न्यूनतम / वांछित / अधिकतम | | Gradient / Mesh / Blend | ग्रेडिएंट / मेश / ब्लेंड | | Pattern / Swatch | पैटर्न / स्वॉच | | Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन | @@ -72,7 +82,10 @@ percentage of the application's entire interface. On 2026-11-13 the catalog has 8,732 rows, including 3,003 `@msg`, 47 `@msgplural` and 41 `@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in messages for meaning, dropped clauses, English left in place and the save, swatch, crop, -resolution, path and type terms. It is not a native proofread. +resolution, path and type terms. A second pass on that date corrected 85 short labels and +messages that used a word's unrelated sense, such as देसी (local) for Desired, शासक (a +sovereign) for a ruler and उच्च न्यायालय (a high court) for High Contrast. It is not a native +proofread. The comparison covers release `v0.21.1` (`f6aa4309`) and current `main` (`3c7d9e1f`). Their non-English catalogs have no missing Hindi keys except the names deliberately kept in English. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index db3d26f7ce..4fd88f73dc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -29,7 +29,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | +| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, and a word-sense pass on short labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..87e6844bc3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -856,6 +856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged. + - Hindi word senses (2026-11-13): 85 labels and messages use the interface sense of their English: Desired is वांछित and the range message keeps both comparisons, rulers are रूलर, text characters कैरेक्टर, vector points पॉइंट, fills फ़िल्स and High Contrast उच्च कंट्रास्ट; the glossary test rejects the unrelated senses in every row; native review remains; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). From 78889ce8af7d351a6f16a35ef1804d11c805f5bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:37:31 -0800 Subject: [PATCH 02/45] Transform: Scale and Reflect act along a rotated bounding box The Scale and Reflect tools, object.scale, object.reflect and the Transform panel flips act along a rotated bounding box instead of the page axes, until Object > Transform > Reset Bounding Box, as Free Transform does. The Scale and Reflect dialogs and the Properties panel flips run the same commands and follow the box too. - object.scale and object.reflect with a named axis take basis?: "box"|"page" ("box" by default; "page" keeps the page axes; any other value is an error before anything changes). The journal records the basis, so recorded actions replay in the same frame; an older recording without it acts along the box. - A numeric Reflect axis and a Reflect axis drawn with the tool stay lines on the page. While a Reflect drag has not drawn an axis away from the reference point, the tool uses the box's vertical axis, and Shift steps 45 degrees from the box. - Mixed-angle selections and anchors selected with Direct Selection use the page axes; an Alt-drag copy of such anchors scales the whole objects along their box. Transform Again repeats the page matrix. Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- crates/engine/src/cmd/object/commands.rs | 4 +- crates/engine/src/cmd/object/transform.rs | 43 +++++-- crates/engine/src/tests/bboxrotate.rs | 88 ++++++++++++++- crates/engine/src/tests/gradient.rs | 5 +- crates/engine/src/tests/journal.rs | 2 +- crates/engine/src/tests/scalestrokes.rs | 2 +- crates/mcp/src/tools/definitions.rs | 2 +- crates/tools/src/xform/transform.rs | 130 +++++++++++++++++++--- docs/mcp.md | 18 +++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 2 +- 11 files changed, 265 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 5c66ec8442..e079b0aa8c 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Scale…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes), copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, scale ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index d102068947..1eac08e571 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -1,7 +1,7 @@ use super::align::choice; use super::{ - Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, - check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, + Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, + bool_or, check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, selected_roots_to_transform, }; @@ -358,26 +358,47 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { if sx == 0.0 || sy == 0.0 { return Err(bad("object.scale", "scale must be non-zero")); } - apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) + let frame = basis_frame(s, p, "object.scale", &ids)?; + apply_transform(s, "Scale", ids, about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), p, true) +} + +/// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: +/// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset +/// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with +/// Direct Selection (and no copy) transform along the page's axes, as their own box does. The +/// basis joins the journal entry, so a replay acts in the same frame. +fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { + let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page"); + let st = s.doc()?; + let anchors = !bool_or(p, "copy", false) && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; + s.note_journal("basis", json!(if page { "page" } else { "box" })); + Ok(OrientedBox { angle, rect: Rect::ZERO }) } pub(super) fn reflect(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; - let m = match p.get("axis") { - Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), - Some(Value::Number(n)) => { - let t = -n.as_f64().unwrap_or(90.0).to_radians(); - Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) - } - None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), - Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), + // A named axis is the bounding box's own (see [`basis_frame`]); an angle is a line on the page. + let named = match p.get("axis") { + Some(Value::String(a)) if a == "horizontal" => Some(Affine::scale_non_uniform(1.0, -1.0)), + Some(Value::Number(_)) => None, + None | Some(Value::Null) => Some(Affine::scale_non_uniform(-1.0, 1.0)), + Some(Value::String(a)) if a == "vertical" => Some(Affine::scale_non_uniform(-1.0, 1.0)), Some(v) => { let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); } }; + let m = match named { + Some(m) => basis_frame(s, p, "object.reflect", &ids)?.conjugate(m), + None => { + choice(p, "object.reflect", "basis", &["box", "page"])?; + let t = -p.get("axis").and_then(Value::as_f64).unwrap_or(90.0).to_radians(); + Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) + } + }; apply_transform(s, "Reflect", ids, about(o, m), p, true) } diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 8907b21cc0..0a6df22e10 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, OrientedBox}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -379,10 +379,96 @@ fn page_flips_keep_an_upright_box_upright() { s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); assert_eq!(angle(&s, a), 0.0); s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + // A named axis is the turned box's own: the box keeps its angle. s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + assert!(near(angle(&s, a), 30.0)); + s.execute("object.reflect", &json!({"axis": "vertical", "basis": "page"})).unwrap(); assert!(near(angle(&s, a), -30.0)); } +fn same_matrix(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// After a rotation, Scale and a named Reflect act along the turned bounding box until Reset +/// Bounding Box; `basis: "page"` keeps the page's axes, and Transform Again repeats the matrix the +/// transform applied on the page. +#[test] +fn scale_and_reflect_follow_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let b = selection_box(&s); + assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}"); + assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0), "{b:?}"); + assert!(b.center().distance(c) < 1e-6, "{b:?}"); + let want = Affine::translate(c.to_vec2()) * frame.conjugate(Affine::scale_non_uniform(2.0, 1.0)) * Affine::translate(-c.to_vec2()); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + assert!(same_matrix(m, want), "{m:?}"); + // The recorded step names its basis, so a replay acts in the same frame. + let steps = s.journal_for_action(0); + assert!(steps.iter().any(|(id, p)| id == "object.scale" && p["basis"] == "box"), "{steps:?}"); + + // Reflected across the box's vertical axis through its left side: the box keeps its angle and + // size, and its centre moves a width to the left along the box. + let left = b.reference_point(3); + s.execute("object.reflect", &json!({"axis": "vertical", "origin": [left.x, left.y]})).unwrap(); + let r = selection_box(&s); + let moved = c + (frame.to_doc() * Point::new(-200.0, 0.0)).to_vec2(); + assert!(near(r.angle, 45.0) && near(r.rect.width(), 200.0) && near(r.rect.height(), 50.0), "{r:?}"); + assert!(r.center().distance(moved) < 1e-6, "{r:?}"); + // Across its horizontal axis through its centre: the box stays where it is. + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + assert!(selection_box(&s).center().distance(moved) < 1e-6 && near(angle(&s, a), 45.0)); + + // Unknown bases are errors and change nothing. + let n = undo_len(&s); + assert!(s.execute("object.scale", &json!({"sx": 50, "basis": "turned"})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": "vertical", "basis": 1})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": 30, "basis": "turned"})).is_err()); + assert_eq!(undo_len(&s), n); + + // On the page's axes, the box no longer stands at 45°. + s.execute("object.scale", &json!({"sx": 50, "sy": 100, "basis": "page"})).unwrap(); + assert!(!near(angle(&s, a), 45.0)); + // Transform Again repeats the page matrix after Reset Bounding Box too. + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().clone(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let mut want = before; + want.transform(m, false); + let (got, want) = (page(&s, &[a]), want.geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap()); + assert!([got.x0 - want.x0, got.y0 - want.y0, got.x1 - want.x1, got.y1 - want.y1].iter().all(|d| d.abs() < 1e-6), "{got:?} {want:?}"); +} + +/// Once Reset Bounding Box squares the box, and for objects standing at different angles, Scale +/// acts along the page's axes. +#[test] +fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let w = page(&s, &[a]); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let r = page(&s, &[a]); + assert!(near(r.width(), 2.0 * w.width()) && near(r.height(), w.height()), "{w:?} {r:?}"); + assert_eq!(angle(&s, a), 0.0); + // A turned rectangle with an upright one: the box is square to the page. + let b = turned_rect(&mut s); + let u = rect(&mut s, 400.0, 100.0, 50.0, 50.0); + sel(&mut s, &[b, u]); + let w = page(&s, &[b, u]); + s.execute("object.scale", &json!({"sx": 100, "sy": 200})).unwrap(); + let r = page(&s, &[b, u]); + assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a2aea6341d..2ad63326f5 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -219,9 +219,10 @@ fn rotate_reflect_and_scale_carry_unplaced_gradients() { s.execute("object.rotate", &json!({"angle": 90})).unwrap(); let g = fill_geom(&s, id); assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}"); - s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap(); let r = fill_geom(&s, id); - // The vertical vector is centred on the object, so the reflection swaps its ends. + // The vertical vector is centred on the object, so the reflection across the page's + // horizontal axis swaps its ends. assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}"); // A circle's radial gradient becomes an ellipse under a non-uniform scale. diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 5eb66ec6c8..89b38869bd 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -353,7 +353,7 @@ fn a_batch_step_records_what_it_took_from_the_preferences() { ]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); let p = &s.journal.last().unwrap().1; - assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false})); + assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false, "basis": "box"})); // Replayed with other preferences, the scale still scales the stroke. let r = replayed(&s); assert!(!r.prefs.scale_strokes); diff --git a/crates/engine/src/tests/scalestrokes.rs b/crates/engine/src/tests/scalestrokes.rs index d03a9ebf8c..4d8349b6a4 100644 --- a/crates/engine/src/tests/scalestrokes.rs +++ b/crates/engine/src/tests/scalestrokes.rs @@ -216,7 +216,7 @@ fn the_journal_records_the_scale_options_used() { run(&mut s, "object.scale", json!({"sx": 150})); let (cmd, p) = s.journal.last().unwrap().clone(); assert_eq!(cmd, "object.scale"); - assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true})); + assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true, "basis": "box"})); // An explicit value stays; moves don't scale, so they record nothing. run(&mut s, "object.scale", json!({"sx": 150, "strokes": true})); assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true)); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index d4d69c945f..5bc1018730 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -465,7 +465,7 @@ pub fn tool_definitions() -> Vec { tool( "transform", "Transform", - "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", + "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. Scale and reflect act along the selection's bounding box, turned with rotated objects until Reset Bounding Box. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", obj( json!({ "ids": {"type": "array", "items": {"type": "integer"}}, diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 263c0ea5e4..8f3f3b1768 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -8,10 +8,12 @@ //! while hovering, so transforms pivot exactly on a corner. A drag starts from the point it grabbed //! snapped the same way, and the pointer snaps as a drawn point does (Smart Guides: the other //! objects' anchors, centres and edges; or the grid, pixels, Snap to Point), so a corner dragged -//! onto another object's anchor scales exactly onto it. +//! onto another object's anchor scales exactly onto it. Scale stretches along the bounding box's +//! own width and height, turned with rotated objects until Reset Bounding Box; Reflect's default +//! axis is the box's vertical one, and Shift constrains a drawn axis to 45° steps from the box. use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; @@ -68,9 +70,26 @@ struct Drag { /// Alt was down at the press: a click opens the dialog, a drag transforms a copy. alt: bool, bounds: Rect, + /// The angle of the selection's bounding box: Scale and Reflect act along its axes + /// ([`TransformTool::matrix_in`]). + angle: f64, + /// Anchors are selected with Direct Selection: they transform alone along the page's axes, + /// and a copy transforms the whole objects along the box ([`Drag::angle_for`]). + anchors: bool, + /// The snapped pointer and Shift at the last drag event, to work out the transform again + /// when the Alt state at the release changes what it transforms. + pointer: Point, + shift: bool, last: Affine, } +impl Drag { + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not. + fn angle_for(&self, copy: bool) -> f64 { + if self.anchors && !copy { 0.0 } else { self.angle } + } +} + pub struct TransformTool { kind: TransformKind, /// Custom reference point and the selection it was set for. @@ -125,6 +144,16 @@ impl TransformTool { /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + self.matrix_in(0.0, o, start, p, shift, bounds) + } + + /// [`TransformTool::matrix_for`] with the bounding box turned by `angle` (counter-clockwise + /// degrees): Scale stretches along the box's own width and height, and Reflect's default axis + /// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the + /// line the pointer draws on the page. + pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + let frame = OrientedBox { angle, rect: Rect::ZERO }; + let local = |v: Vec2| frame.to_doc().inverse() * v.to_point(); match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); @@ -132,15 +161,17 @@ impl TransformTool { } TransformKind::Reflect => { let mut v = p - o; - if shift { - v = vectorcraft_geom::constrain_angle(v, 45.0); + if v.hypot() < 1e-9 { + v = (frame.to_doc() * Point::new(0.0, 1.0)).to_vec2(); + } else if shift { + v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2(); } - let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) }; + let theta = v.y.atan2(v.x); (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) } TransformKind::Scale => { - let d0 = start - o; - let d1 = p - o; + let d0 = local(start - o).to_vec2(); + let d1 = local(p - o).to_vec2(); let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 }; let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y)); if shift { @@ -151,7 +182,7 @@ impl TransformTool { } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); - (about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) } TransformKind::Shear => { // The shear axis follows the dominant drag direction. Shift keeps the drag on that @@ -227,7 +258,21 @@ impl Tool for TransformTool { // Alt: decided on release. A click sets the point and opens the dialog; a drag // transforms a copy about the reference point as it was. let origin = self.reference_point(cx).unwrap_or(bounds.center()); - self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY }); + let anchors = vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()).is_some(); + let angle = selection_box(cx).map_or(0.0, |b| b.angle); + self.drag = Some(Drag { + start: p, + pick, + origin, + began: false, + alt: ev.mods.alt, + bounds, + angle, + anchors, + pointer: p, + shift: false, + last: Affine::IDENTITY, + }); vec![] } PointerKind::Drag => { @@ -247,8 +292,8 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_for(d.origin, d.pick, p, ev.mods.shift, d.bounds); - d.last = m; + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, p, ev.mods.shift, d.bounds); + (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); out.push(preview(m, ev.mods.alt, ev.backquote)); @@ -257,8 +302,11 @@ impl Tool for TransformTool { PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { - // The Alt state at release decides whether the result is a copy. - vec![preview(d.last, ev.mods.alt, ev.backquote), Action::Commit] + // The Alt state at release decides whether the result is a copy, and with + // anchors selected, whether it acts along the box or the page. + let m = + if d.anchors { self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 } else { d.last }; + vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. self.origin = Some((d.pick, cx.selection.objects.clone())); @@ -578,6 +626,62 @@ mod tests { assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9); } + /// With the selection turned by 30°, a Scale drag stretches along the box's own sides, and + /// Reflect's default axis and Shift steps start from the box's axes while a drawn axis stays + /// the line drawn on the page. + #[test] + fn scale_and_reflect_drags_follow_a_turned_bounding_box() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + d.node_mut(id).unwrap().transform(about(c, Affine::rotate(-30f64.to_radians())), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let frame = OrientedBox { angle: 30.0, rect: Rect::ZERO }; + let along = |x: f64, y: f64| c + (frame.to_doc() * Point::new(x, y)).to_vec2(); + // The right side's middle dragged out to twice its distance: the width doubles along the + // box, and the height stays. + let mut t = TransformTool::new(TransformKind::Scale); + let (from, to) = (along(50.0, 0.0), along(100.0, 0.0)); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + let m = matrix(&a[1]); + assert!((m * along(50.0, 50.0)).distance(along(100.0, 50.0)) < 1e-9, "{m:?}"); + assert!((m * along(0.0, 50.0)).distance(along(0.0, 50.0)) < 1e-9, "{m:?}"); + assert!(a.iter().any(|x| matches!(x, Action::Preview(..)))); + + let t = TransformTool::new(TransformKind::Reflect); + // No axis drawn: the box's vertical axis. + let (m, _) = t.matrix_in(30.0, c, c, c, false, Rect::ZERO); + assert!((m * along(40.0, 10.0)).distance(along(-40.0, 10.0)) < 1e-9, "{m:?}"); + // Shift: 39° from the box's horizontal axis steps to 45° from it. + let (m, text) = t.matrix_in(30.0, c, c, along(10.0, 8.0), true, Rect::ZERO); + assert!((m * along(10.0, 0.0)).distance(along(0.0, 10.0)) < 1e-9, "{m:?}"); + assert_eq!(text, "-15.0°"); + // A drawn axis is the line on the page. + let (m, _) = t.matrix_in(30.0, c, c, c + Vec2::new(0.0, -50.0), false, Rect::ZERO); + assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9, "{m:?}"); + + // Anchors selected with Direct Selection scale alone along the page's axes; Alt at the + // release copies the whole object, which scales along its box. + let mut s = s.clone(); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let mut cx = crate::testutil::cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let page_axes = |m: Affine| m.as_coeffs()[1].abs() < 1e-9 && m.as_coeffs()[2].abs() < 1e-9; + let (from, to) = (along(50.0, 0.0), along(100.0, 30.0)); + for copy in [false, true] { + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + assert!(page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); + assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); + } + } + #[test] fn shear_horizontal_keeps_origin_row() { let t = TransformTool::new(TransformKind::Shear); diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..b5434c6abc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5137,6 +5137,24 @@ used, as a transform's does. {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` +## Scale and Reflect on a turned bounding box + +`object.scale` and `object.reflect` with a named axis act along the selection's bounding box: after a rotation, `sx` +stretches along the box's own width and `sy` along its height, and `"vertical"` and `"horizontal"` are the box's own +axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: +`"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. +`origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose +objects stand at different angles, and anchors selected with Direct Selection, use the page's axes. The journal entry +records the `basis` used, so a recorded action replays in the same frame; an older recording without `basis` acts +along the box. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale +and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis +and Shift steps follow the box the same way; a Reflect axis drawn with the tool is the line drawn on the page. + +```json +{"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"sy":100}}} +{"name":"run_command","arguments":{"command":"object.reflect","params":{"axis":"vertical","basis":"page"}}} +``` + ## Free Distort on a turned bounding box `object.distort` maps the corners of `from` (TL, TR, BR, BL) onto `corners`, which are page coordinates. With diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..5537e9a950 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -875,7 +875,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`). The Scale and Reflect tools, their dialogs and commands, and the Transform panel's flips keep the page's axes (VectorCraft's own rule; `bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`) (`bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Scale and Reflect tools, the Scale and Reflect dialogs, `object.scale`, `object.reflect` with a named axis and the Transform and Properties panels' flips act along the selection's turned bounding box until Reset Bounding Box: the horizontal and vertical scale stretch along its own width and height, and the vertical and horizontal reflection axes are its own. The Reflect tool's default axis and its Shift steps start from the box's axes, and an axis drawn with the tool is the line drawn on the page. `basis: "page"` keeps the page's axes (VectorCraft's own rules; `bboxrotate::scale_and_reflect_follow_the_turned_box`, tools `xform::transform::tests::scale_and_reflect_drags_follow_a_turned_bounding_box`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d640d3b5d2..de4d065354 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -266,7 +266,7 @@ tools, never checked side by side) isn't captured row by row. | Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | | | Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | | | Vector Halftone Preview keeps the selection box and hides selection marks on the temporary dots | done | #6369; Preview off and Cancel restore the original marks. | -| Free Transform, Scale and Reflect along a rotated box | missing | | +| Free Transform, Scale and Reflect along a rotated box | done | The Scale and Reflect tools, dialogs and commands and the Transform and Properties panels' flips act along the turned box until Reset Bounding Box; `basis: "page"` keeps the page's axes | | Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | | | Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | with anchors selected with Direct Selection, the tools, the dialogs and Move transform those anchors alone, about the centre of their box by default (#1408; the default point and whole-object copies are our choices) | | Move, Rotate, Scale, Reflect and Shear dialogs: live Preview; Up/Down step angles and percentages by 1, Shift by 10 | done | #1412; Preview starts off (our choice: the public documentation gives no default), Move's as it was last confirmed; OK is one undo step and Transform Again repeats it | From b1c9783e40bf9b83075c37fdb8bd14f43b6317bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:54:42 -0800 Subject: [PATCH 03/45] Tests: expect scale to follow the rotated bounding box A stretch of a turned star along its own box keeps it a parametric shape; the page-axes stretch that skews it now passes basis "page". The no-op transform test expects the scale's journal entry to carry its basis. --- crates/engine/src/cmd/addons/pathtools/tests.rs | 7 ++++++- crates/engine/src/tests.rs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..87c61a87f3 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -385,8 +385,13 @@ fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() { s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap(); assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1)); assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays"); - // Stretched across its own axes (it is turned): skewed, so no longer. + // Stretched along its turned bounding box: still the shape, wider along its own axis. s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap(); + let r = shape_of(&s, id).unwrap(); + assert!((r.width - 300.0).abs() < 1e-6 && (r.height - 200.0).abs() < 1e-6 && (r.rotation + 30.0).abs() < 1e-6, "{r:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + // Stretched across the page's axes (it is turned): skewed, so no longer. + s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140], "basis": "page"})).unwrap(); assert_eq!(shape_of(&s, id), None); s.execute("edit.undo", &json!({})).unwrap(); assert!(shape_of(&s, id).is_some(), "undo brings it back"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c231dc7e5..e2a4129e57 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1271,7 +1271,12 @@ fn a_transform_that_moves_nothing_records_no_undo_step() { for (id, p) in calls { assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}"); assert_eq!(history(&s), before, "{id} {p}"); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + // The journal adds the frame a scale acted in, so a replay acts in the same one. + let mut recorded = p; + if id == "object.scale" { + recorded["basis"] = json!("box"); + } + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded)); } assert_eq!( s.doc().unwrap().last_transform, From b00e50d4c57348241e15f86dc6dd0c159fa261a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:30:48 -0800 Subject: [PATCH 04/45] Transform: pattern-only Scale and Reflect of selected anchors follow the box With anchors selected and Transform Objects off (objects: false, or the backquote key during a Scale or Reflect drag), the transform moves the whole objects' pattern tiles, so it now acts along the turned bounding box instead of the page axes that apply to the anchors alone. --- crates/engine/src/cmd/object/transform.rs | 8 +++--- crates/engine/src/tests/bboxrotate.rs | 29 ++++++++++++++++++++ crates/tools/src/xform/transform.rs | 32 ++++++++++++++++------- docs/mcp.md | 3 ++- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 1eac08e571..e7a2961285 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -365,12 +365,14 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { /// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: /// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset /// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with -/// Direct Selection (and no copy) transform along the page's axes, as their own box does. The -/// basis joins the journal entry, so a replay acts in the same frame. +/// Direct Selection (with no copy and Transform Objects on) transform along the page's axes, as +/// their own box does. The basis joins the journal entry, so a replay acts in the same frame. fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page"); let st = s.doc()?; - let anchors = !bool_or(p, "copy", false) && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let anchors = !bool_or(p, "copy", false) + && transforms_objects(p) + && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; s.note_journal("basis", json!(if page { "page" } else { "box" })); Ok(OrientedBox { angle, rect: Rect::ZERO }) diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 0a6df22e10..3721e332d3 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -469,6 +469,35 @@ fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); } +/// With anchors selected, Scale and a named Reflect move the anchors along the page's axes, but +/// with Transform Objects off they move the whole objects' pattern tiles, along the turned box. +#[test] +fn pattern_only_transforms_of_selected_anchors_follow_the_turned_box() { + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + let last = |s: &Session| match s.doc().unwrap().last_transform.clone() { + Some(LastTransform::Matrix(m, _)) => m, + other => panic!("no matrix to repeat: {other:?}"), + }; + let scale = Affine::scale_non_uniform(2.0, 1.0); + let flip = Affine::scale_non_uniform(-1.0, 1.0); + for (cmd, p, m) in [("object.scale", json!({"sx": 200, "sy": 100}), scale), ("object.reflect", json!({"axis": "vertical"}), flip)] { + for objects in [false, true] { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let mut p = p.clone(); + p["origin"] = json!([c.x, c.y]); + p["objects"] = json!(objects); + p["patterns"] = json!(true); + s.execute(cmd, &p).unwrap(); + let want = if objects { about(m) } else { about(frame.conjugate(m)) }; + assert!(same_matrix(last(&s), want), "{cmd} objects {objects}: {:?}", last(&s)); + } + } +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 8f3f3b1768..47cd5b178a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -74,7 +74,8 @@ struct Drag { /// ([`TransformTool::matrix_in`]). angle: f64, /// Anchors are selected with Direct Selection: they transform alone along the page's axes, - /// and a copy transforms the whole objects along the box ([`Drag::angle_for`]). + /// and a copy or a pattern-only drag transforms the whole objects along the box + /// ([`Drag::angle_for`]). anchors: bool, /// The snapped pointer and Shift at the last drag event, to work out the transform again /// when the Alt state at the release changes what it transforms. @@ -84,9 +85,10 @@ struct Drag { } impl Drag { - /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not. - fn angle_for(&self, copy: bool) -> f64 { - if self.anchors && !copy { 0.0 } else { self.angle } + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not, and that + /// transforms only pattern tiles (`patterns_only`, the backquote key) or not. + fn angle_for(&self, copy: bool, patterns_only: bool) -> f64 { + if self.anchors && !copy && !patterns_only { 0.0 } else { self.angle } } } @@ -292,7 +294,7 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, p, ev.mods.shift, d.bounds); + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); @@ -302,10 +304,14 @@ impl Tool for TransformTool { PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { - // The Alt state at release decides whether the result is a copy, and with - // anchors selected, whether it acts along the box or the page. - let m = - if d.anchors { self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 } else { d.last }; + // The Alt and backquote states at release decide whether the result is a copy + // or moves only pattern tiles, and with anchors selected, whether it acts + // along the box or the page. + let m = if d.anchors { + self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 + } else { + d.last + }; vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. @@ -680,6 +686,14 @@ mod tests { let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); } + // With the backquote key the drag moves only the whole objects' pattern tiles, which + // scale along the box, both while dragging and at the release. + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.x, to.y) }); + assert!(!page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.x, to.y) }); + assert!(!page_axes(matrix(&up[0])), "{up:?}"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index b5434c6abc..ffb534472e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5144,7 +5144,8 @@ stretches along the box's own width and `sy` along its height, and `"vertical"` axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: `"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. `origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose -objects stand at different angles, and anchors selected with Direct Selection, use the page's axes. The journal entry +objects stand at different angles, and anchors selected with Direct Selection, use the page's axes; with `copy` or +with `objects: false`, the whole objects or their pattern tiles transform, along the box. The journal entry records the `basis` used, so a recorded action replays in the same frame; an older recording without `basis` acts along the box. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis From f9e796cb10c2befc1560c1c794ab54b5803da6cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:01:02 -0800 Subject: [PATCH 05/45] Live Paint: double-click floods across unstroked edges, triple-click paints the same paint With the Live Paint Bucket, a double-click on a face fills it and every face reached across edges that are hidden or draw no stroke; a stroked edge, a hidden face and a Gap Detection gap stop it. On an edge, a double-click paints the edges joined to it end to end with the same stroke paint. A triple-click fills every face of the group with the clicked face's fill, or paints every edge with the clicked edge's stroke. Shift and the Paint Fills and Paint Strokes options choose a face or an edge as for a click; Alt held paints nothing. - livePaint.fill and livePaint.strokeEdge take scope ("one", the default, "connected" or "samePaint") and match, the paint compared, and reply with count. A bad scope or match is an error, and scope with a drag's points is refused. - Each click of a double-click or triple-click paints as it arrives, as its own undo step. For a triple-click, and for a double-click on an edge, the tool sends as match the paint the face or edge had before the first of those clicks, including a first click that makes the Live Paint group from the selected paths. A double-click on a face sends no match. - PointerKind::TripleClick is new: the canvas sends it on egui's triple-click unless a modal dialog is open, as for a double-click, and ui.pointer and pointer_gesture take "tripleclick". Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 296 +++++++++++++++- crates/engine/src/tests/build.rs | 326 ++++++++++++++++++ crates/engine/src/tooling.rs | 2 +- crates/mcp/src/headless.rs | 3 +- crates/mcp/src/tests.rs | 1 + crates/mcp/src/tests/livepaint.rs | 49 +++ crates/mcp/src/tools/definitions.rs | 2 +- crates/mcp/src/tools/dispatch.rs | 2 +- crates/tools/src/addons/corner_tool.rs | 1 + crates/tools/src/addons/extend_path.rs | 2 +- crates/tools/src/addons/parametric_shape.rs | 2 +- crates/tools/src/addons/slide_anchor.rs | 2 +- crates/tools/src/addons/smart_pen.rs | 1 + crates/tools/src/builder/live_paint.rs | 116 ++++++- crates/tools/src/builder/tests.rs | 72 ++++ crates/tools/src/cropimage.rs | 1 + crates/tools/src/distort/puppet.rs | 2 +- crates/tools/src/distort/width.rs | 1 + crates/tools/src/draw2/curvature.rs | 1 + crates/tools/src/input.rs | 3 + crates/tools/src/pen.rs | 2 +- crates/tools/src/printtiling.rs | 2 +- crates/tools/src/text/tool.rs | 1 + crates/tools/src/touchtype.rs | 2 +- crates/tools/src/xform/gradient.rs | 2 +- crates/tools/src/xform/transform.rs | 1 + crates/ui-egui/src/canvas/input.rs | 23 ++ crates/ui-egui/src/control.rs | 5 +- docs/control-protocol.md | 2 +- docs/mcp.md | 20 +- docs/target-app-parity.md | 1 + 32 files changed, 917 insertions(+), 33 deletions(-) create mode 100644 crates/mcp/src/tests/livepaint.rs diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 94fe55c119..790d54214b 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match), not with points, match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face, count (faces filled)}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges whose stroke paint is match) | \"samePaint\" (a triple-click: also every edge of the group whose stroke paint is match), match?: paint (as the document stores it; scope connected or samePaint only; default: the edge's stroke paint), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges → {group, edge, count (edges painted)}", has_doc, lp_stroke_edge ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index e5e7757486..faeaa20cbc 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -269,9 +269,257 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) } +/// Which parts a Live Paint Bucket click paints besides the one under the pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LpScope { + /// The part under the pointer alone (a click). + One, + /// A face and the faces reached from it across unstroked edges, or an edge and the edges + /// joined to it that have the same stroke (a double-click). + Connected, + /// Every face with the same fill, or every edge with the same stroke, in the group (a + /// triple-click). + SamePaint, +} + +/// The `scope` of `livePaint.fill` and `livePaint.strokeEdge`: `one` when left out or null. +fn scope_param(p: &Value, c: &str) -> Result { + match p.get("scope").filter(|v| !v.is_null()) { + None => Ok(LpScope::One), + Some(v) => match v.as_str() { + Some("one") => Ok(LpScope::One), + Some("connected") => Ok(LpScope::Connected), + Some("samePaint") => Ok(LpScope::SamePaint), + _ => Err(bad(c, "scope must be \"one\", \"connected\" or \"samePaint\"")), + }, + } +} + +/// The `match` of `livePaint.fill` and `livePaint.strokeEdge`: a paint as the document stores it, +/// None when left out or null. With `allowed` false (a scope that compares no paints) a `match` is +/// an error naming `scopes`, the scopes it applies to. +fn match_param(p: &Value, c: &str, allowed: bool, scopes: &str) -> Result> { + let Some(v) = p.get("match").filter(|v| !v.is_null()) else { return Ok(None) }; + if !allowed { + return Err(bad(c, format!("match applies only with scope {scopes}"))); + } + serde_json::from_value::(v.clone()).map(Some).map_err(|_| bad(c, "match must be a paint as the document stores it")) +} + +/// The greatest distance (pt) between two points that the Live Paint Bucket takes as one point: +/// where edges meet, and where a face's outline runs along an edge. +const LP_JOIN_TOL: f64 = 1e-4; + +/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within +/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a +/// coordinate too large or not finite does not overflow. +fn lp_cell(p: Point) -> (i64, i64) { + ((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64) +} + +/// Whether `edge` draws no stroke: its stroke paint is none or its weight is 0. +fn unstroked(edge: &Node) -> bool { + edge.appearance.stroke_paint().is_none() || edge.appearance.stroke_width() <= 0.0 +} + +/// The segments of `pd`. +fn lp_segments(pd: &PathData) -> Vec { + pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect() +} + +/// The faces of Live Paint group `group` that a flood from face `start` reaches: `start`, and each +/// face that shares with a face already reached an edge that is hidden or draws no stroke +/// ([`unstroked`]). Hidden faces are not reached and do not pass the flood on. A face runs along an +/// edge where a segment of its outline has its middle within [`LP_JOIN_TOL`] of one of the edge's +/// segments, over the part of that segment between its outline segment's ends; two faces share the +/// edge where those parts overlap along more than [`LP_JOIN_TOL`], so two faces that a Gap +/// Detection gap parts on the same side of an edge, whose parts only touch, do not. +fn flood_faces(group: &Node, start: NodeId) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + // Each edge segment, and whether a flood crosses it. + let edges: Vec<(kurbo::CubicBez, bool)> = b::edges(group) + .into_iter() + .flat_map(|e| { + let cross = !e.visible || unstroked(e); + e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross)) + }) + .collect(); + let mut mids: std::collections::HashMap<(i64, i64), Vec<(Point, usize)>> = std::collections::HashMap::new(); + for (k, (s, _)) in edges.iter().enumerate() { + let m = s.eval(0.5); + mids.entry(lp_cell(m)).or_default().push((m, k)); + } + // The faces along each edge segment a flood crosses, each with the part of the segment it runs + // along (from and to, in the segment's own parameter). A face outline segment is usually an + // edge segment, with the same middle; one that is not (a piece of an edge segment where a Gap + // Detection line meets it, or the line itself) is measured against the edge segments near it. + let mut along: std::collections::HashMap> = std::collections::HashMap::new(); + let mut add = |k: usize, id: NodeId, piece: &kurbo::CubicBez| { + if let Some((s, true)) = edges.get(k) { + let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t); + along.entry(k).or_default().push((id, a.min(c), a.max(c))); + } + }; + let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![]; + for f in b::faces(group).into_iter().filter(|f| f.visible) { + for piece in f.path_data().map(lp_segments).unwrap_or_default() { + let m = piece.eval(0.5); + let (x, y) = lp_cell(m); + let same = (-1..=1) + .flat_map(|dx| (-1..=1).map(move |dy| (x.saturating_add(dx), y.saturating_add(dy)))) + .flat_map(|c| mids.get(&c).map(Vec::as_slice).unwrap_or_default()) + .find(|(q, _)| (*q - m).hypot() <= LP_JOIN_TOL); + match same { + Some(&(_, k)) => add(k, f.id, &piece), + None => pieces.push((piece, f.id)), + } + } + } + if !pieces.is_empty() { + let grid = LpEdgeGrid::new(&edges); + for (piece, id) in pieces { + let m = piece.eval(0.5); + for k in grid.near(m) { + if edges.get(k).is_some_and(|(s, cross)| *cross && s.nearest(m, 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) { + add(k, id, &piece); + } + } + } + } + let mut next: std::collections::HashMap> = std::collections::HashMap::new(); + for (k, faces) in &along { + let Some((s, _)) = edges.get(*k) else { continue }; + for (i, &(a, a0, a1)) in faces.iter().enumerate() { + for &(c, c0, c1) in faces.iter().skip(i + 1) { + let (from, to) = (a0.max(c0), a1.min(c1)); + if a != c && from < to && s.subsegment(from..to).arclen(1e-6) > LP_JOIN_TOL { + next.entry(a).or_default().push(c); + next.entry(c).or_default().push(a); + } + } + } + } + let mut reached = vec![start]; + let mut seen = std::collections::HashSet::from([start]); + let mut i = 0; + while let Some(&f) = reached.get(i) { + i += 1; + for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() { + if seen.insert(n) { + reached.push(n); + } + } + } + reached +} + +/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for +/// every point. +const LP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in. +const LP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment) +/// that their bounds, widened by [`LP_JOIN_TOL`], cover. +struct LpEdgeGrid { + origin: Point, + cell: f64, + /// Segment indices by cell. + cells: std::collections::HashMap<(i64, i64), Vec>, + /// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point. + wide: Vec, +} + +impl LpEdgeGrid { + /// The grid of the segments of `edges` that a flood crosses (those marked true). + fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self { + use kurbo::ParamCurveExtrema; + let boxes: Vec<(usize, Rect)> = + edges.iter().enumerate().filter(|(_, e)| e.1).map(|(k, (s, _))| (k, s.bounding_box().inflate(LP_JOIN_TOL, LP_JOIN_TOL))).collect(); + let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default(); + let side = (boxes.len() as f64).sqrt().ceil().max(1.0); + let cell = all.width().max(all.height()) / side; + let cell = if cell.is_finite() && cell > LP_JOIN_TOL { cell } else { LP_JOIN_TOL }; + let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] }; + for (k, b) in boxes { + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN { + grid.wide.push(k); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push(k); + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose widened bounds may hold `p`. + fn near(&self, p: Point) -> impl Iterator + '_ { + self.wide.iter().chain(self.cells.get(&self.cell_of(p)).into_iter().flatten()).copied() + } +} + +/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through +/// edges with stroke paint `paint`: `start`, and each edge with that stroke paint that has a +/// segment end within [`LP_JOIN_TOL`] of a segment end of an edge already reached. +fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec { + let edges: Vec<&Arc> = + b::edges(group).into_iter().filter(|e| e.visible && (e.id == start || e.appearance.stroke_paint() == *paint)).collect(); + let ends: Vec> = edges + .iter() + .map(|e| { + e.path_data() + .map(|pd| pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i)).flat_map(|c| [c.p0, c.p3])).collect()) + .unwrap_or_default() + }) + .collect(); + let mut at: std::collections::HashMap<(i64, i64), Vec<(Point, usize)>> = std::collections::HashMap::new(); + for (i, pts) in ends.iter().enumerate() { + for &p in pts { + at.entry(lp_cell(p)).or_default().push((p, i)); + } + } + let Some(first) = edges.iter().position(|e| e.id == start) else { return vec![] }; + let mut reached = vec![first]; + let mut seen = std::collections::HashSet::from([first]); + let mut k = 0; + while let Some(&i) = reached.get(k) { + k += 1; + for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() { + let (x, y) = lp_cell(p); + for dx in -1..=1 { + for dy in -1..=1 { + for &(q, j) in at.get(&(x.saturating_add(dx), y.saturating_add(dy))).map(Vec::as_slice).unwrap_or_default() { + if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) { + reached.push(j); + } + } + } + } + } + } + reached.into_iter().filter_map(|i| edges.get(i).map(|e| e.id)).collect() +} + pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let scope = scope_param(p, C)?; + let like = match_param(p, C, scope == LpScope::SamePaint, "\"samePaint\"")?; + if scope != LpScope::One && p.get("points").is_some() { + return Err(bad(C, "scope applies to a click: leave out points")); + } let parse_chunk = |input: &Value, start: Point| -> Result> { let values = input .as_array() @@ -305,7 +553,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; - let (g, face) = s.edit("Live Paint Bucket", |d, sel| { + let (g, face, count) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { Some(g) => { if !d.node(g).is_some_and(is_live_paint) { @@ -332,6 +580,18 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; let mut targets = std::collections::BTreeSet::from([fid]); + if scope != LpScope::One { + let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; + if scope == LpScope::Connected { + targets.extend(flood_faces(n, fid)); + } else { + let like = match &like { + Some(m) => m.clone(), + None => d.node(fid).ok_or(EngineError::NoNode(fid))?.appearance.fill_paint(), + }; + targets.extend(b::faces(n).into_iter().filter(|f| f.visible && f.appearance.fill_paint() == like).map(|f| f.id)); + } + } if let Some(points) = points { let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; // Use the existing region-crossing probes, so even a face narrower than the distance @@ -355,13 +615,14 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); } } + let count = targets.len(); for target in targets { d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); } - Ok((gid, fid)) + Ok((gid, fid, count)) })?; s.remember_paint(&paint); - Ok(json!({ "group": g.0, "face": face.0 })) + Ok(json!({ "group": g.0, "face": face.0, "count": count })) } pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { @@ -375,18 +636,31 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "width out of range")); } let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); - let edge = s.edit("Live Paint Bucket", |d, _| { + let scope = scope_param(p, C)?; + let like = match_param(p, C, scope != LpScope::One, "\"connected\" or \"samePaint\"")?; + let (edge, count) = s.edit("Live Paint Bucket", |d, _| { let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; - let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; - e.appearance.set_stroke(paint.clone()); - if let Some(st) = e.appearance.stroke_mut() { - st.width = width; + let start = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let eid = start.id; + let like = like.clone().unwrap_or_else(|| start.appearance.stroke_paint()); + let targets = match scope { + LpScope::One => vec![eid], + LpScope::Connected => connected_edges(n, eid, &like), + LpScope::SamePaint => std::iter::once(eid) + .chain(b::edges(n).into_iter().filter(|e| e.visible && e.id != eid && e.appearance.stroke_paint() == like).map(|e| e.id)) + .collect(), + }; + for target in &targets { + let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?; + e.appearance.set_stroke(paint.clone()); + if let Some(st) = e.appearance.stroke_mut() { + st.width = width; + } } - Ok(eid) + Ok((eid, targets.len())) })?; s.remember_paint(&paint); - Ok(json!({ "group": g.0, "edge": edge.0 })) + Ok(json!({ "group": g.0, "edge": edge.0, "count": count })) } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 6eeace1073..c0fbd5efe0 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2541,6 +2541,332 @@ fn live_paint_fill_skips_hidden_faces() { assert!(!node(&s, hidden).visible); } +/// A Live Paint group of 50 pt squares side by side, 50 pt tall, with their left sides at each of +/// `xs`. +fn live_squares(s: &mut Session, xs: &[f64]) -> NodeId { + let ids: Vec = xs.iter().map(|&x| rect(s, x, 0.0, 50.0, 50.0).0).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()) +} + +/// The ids of the faces of `g` that hold `p`, in the order given. +fn faces_at(s: &Session, g: NodeId, ps: &[(f64, f64)]) -> Vec { + ps.iter().map(|&(x, y)| vectorcraft_tools::builder::face_at(&node(s, g), Point::new(x, y)).unwrap().id).collect() +} + +/// The edges of `g` whose bounds lie within `r`. +fn edges_in(s: &Session, g: NodeId, r: Rect) -> Vec { + let r = r.inflate(1e-6, 1e-6); + vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter(|e| e.geometric_bounds().is_some_and(|bb| r.contains(Point::new(bb.x0, bb.y0)) && r.contains(Point::new(bb.x1, bb.y1)))) + .map(|e| e.id) + .collect() +} + +/// Give `ids` stroke paint `paint` at weight 1 (none: no stroke), and `fill` to the faces in `faces`. +fn paint_parts(s: &mut Session, ids: &[NodeId], paint: &Paint, faces: &[(NodeId, Paint)]) { + s.edit("t", |d, _| { + for id in ids { + let n = d.node_mut(*id).unwrap(); + n.appearance.set_stroke(paint.clone()); + if let Some(st) = n.appearance.stroke_mut() { + st.width = 1.0; + } + } + for (id, fill) in faces { + d.node_mut(*id).unwrap().appearance.set_fill(fill.clone()); + } + Ok(()) + }) + .unwrap(); +} + +fn fill_of(s: &Session, id: NodeId) -> Paint { + node(s, id).appearance.fill_paint() +} + +/// A double-click on a face fills it and the faces reached across edges that draw no stroke; a +/// stroked edge and a hidden face stop the flood. +#[test] +fn live_paint_bucket_double_click_floods_across_unstroked_edges() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0, 150.0]); + let [a, b, c, d] = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0), (175.0, 25.0)])[..] else { panic!("four faces") }; + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 201.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let stroked = edges_in(&s, g, Rect::new(150.0, 0.0, 150.0, 50.0)); + assert!(!stroked.is_empty()); + paint_parts(&mut s, &stroked, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let white = fill_of(&s, d); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + assert_ne!(white, green); + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!((&r["face"], &r["count"]), (&json!(a.0), &json!(3))); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone(), white.clone()]); + assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!([a, b, c].iter().all(|f| fill_of(&s, *f) != green)); + // A hidden face is not filled and does not pass the flood on. + s.execute("object.setProps", &json!({"ids": [b.0], "visible": false})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!([a, b, c].map(|f| fill_of(&s, f) == green), [true, false, false]); + while s.doc().unwrap().history.undo.len() > depth { + s.execute("edit.undo", &json!({})).unwrap(); + } + // The tool: two clicks and the double-click that follows them. + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, 125.0, 25.0), v).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green, white]); +} + +/// The flood crosses curved edges, and reaches a face 0.012 pt wide. +#[test] +fn live_paint_bucket_double_click_floods_curved_and_narrow_faces() { + let mut s = session(); + let ids: Vec = [0.0, 60.0] + .iter() + .map(|&x| s.execute("shape.ellipse", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 161.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [20, 50], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "both lunes and the lens between them"); + + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 50.0, 50.0).0, rect(&mut s, 49.988, 0.0, 50.0, 50.0).0]; + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let [a, sliver, c] = faces_at(&s, g, &[(25.0, 25.0), (49.994, 25.0), (75.0, 25.0)])[..] else { panic!("three faces") }; + assert!(a != sliver && sliver != c); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!([a, sliver, c].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green]); +} + +/// The flood crosses a hidden edge, and an edge that a Gap Detection gap meets partway along, but +/// not the gap. +#[test] +fn live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 51.0)); + paint_parts(&mut s, &all, &black, &[]); + let fill = |s: &mut Session, g: NodeId, at: [f64; 2]| { + s.execute("livePaint.fill", &json!({"group": g.0, "point": at, "color": "#00ff00", "scope": "connected"})).unwrap()["count"].clone() + }; + assert_eq!(fill(&mut s, g, [25.0, 25.0]), 1, "a stroked edge stops the flood"); + s.execute("edit.undo", &json!({})).unwrap(); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + s.execute("object.setProps", &json!({"ids": middle.iter().map(|e| e.0).collect::>(), "visible": false})).unwrap(); + assert_eq!(fill(&mut s, g, [25.0, 25.0]), 2, "a hidden edge does not"); + + // Square B (100..200 by 0..100) over square C (100..200 by 100..200), and a line down the middle + // of B that stops 2 pt short of their shared edge: Gap Detection closes it, so B is two faces + // that each run along part of the shared edge, which is one edge segment. + let mut s = session(); + let mut ids = vec![rect(&mut s, 100.0, 0.0, 100.0, 100.0).0, rect(&mut s, 100.0, 100.0, 100.0, 100.0).0]; + let line = s.execute("path.create", &json!({"anchors": [{"x": 150, "y": 0}, {"x": 150, "y": 98}]})).unwrap(); + ids.push(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.gapOptions", &json!({"id": g.0, "detect": true, "size": "small"})).unwrap(); + let [b1, b2, c] = faces_at(&s, g, &[(125.0, 50.0), (175.0, 50.0), (150.0, 150.0)])[..] else { panic!("three faces") }; + assert!(b1 != b2 && b2 != c && b1 != c); + let all = edges_in(&s, g, Rect::new(99.0, -1.0, 201.0, 201.0)); + paint_parts(&mut s, &all, &black, &[]); + let shared = edges_in(&s, g, Rect::new(100.0, 100.0, 200.0, 100.0)); + assert!(!shared.is_empty()); + paint_parts(&mut s, &shared, &Paint::None, &[]); + assert_eq!(fill(&mut s, g, [150.0, 150.0]), 3, "from C across the shared edge to both faces of B"); + s.execute("edit.undo", &json!({})).unwrap(); + // With the line and the shared edge stroked, only the gap lies between the two faces of B. + paint_parts(&mut s, &shared, &black, &[]); + assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the flood does not pass the gap"); + + // B alone, its outline drawing no stroke and the line stroked: the two faces of B run along + // different parts of the outline's bottom segment, on the same side, and the flood does not + // join them there. + let mut s = session(); + let mut ids = vec![rect(&mut s, 100.0, 0.0, 100.0, 100.0).0]; + let line = s.execute("path.create", &json!({"anchors": [{"x": 150, "y": 0}, {"x": 150, "y": 98}]})).unwrap(); + ids.push(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.gapOptions", &json!({"id": g.0, "detect": true, "size": "small"})).unwrap(); + assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 2); + let all = edges_in(&s, g, Rect::new(99.0, -1.0, 201.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let line = edges_in(&s, g, Rect::new(150.0, 0.0, 150.0, 98.0)); + assert!(!line.is_empty() && line.len() < all.len()); + paint_parts(&mut s, &line, &black, &[]); + assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the gap parts the two faces along the bottom"); +} + +/// A triple-click on a face fills every face of the group with that face's fill, joined to it or +/// not. Through the tool, the fill compared is the face's fill before the first click. +#[test] +fn live_paint_bucket_triple_click_fills_every_face_of_the_same_fill() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0, 150.0]); + let [a, b, c, d] = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0), (175.0, 25.0)])[..] else { panic!("four faces") }; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 201.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[(a, red.clone()), (b, blue.clone()), (c, red.clone()), (d, red.clone())]); + let stroked = edges_in(&s, g, Rect::new(100.0, 0.0, 100.0, 50.0)); + paint_parts(&mut s, &stroked, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), blue.clone(), green.clone(), green.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + // The tool: the first click fills the face, the double-click floods across x = 50 into B, and + // the triple-click fills the faces that were red. + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [ + PointerKind::Down, + PointerKind::Up, + PointerKind::Down, + PointerKind::Up, + PointerKind::DoubleClick, + PointerKind::Down, + PointerKind::Up, + PointerKind::TripleClick, + ] { + s.pointer(&PointerEvent::new(kind, 25.0, 25.0), v).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone(), green.clone()]); + // A match given by the caller is the fill compared. + while s.doc().unwrap().history.undo.len() > depth { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [red.clone(), blue.clone(), red.clone(), red.clone()]); + let r = s + .execute( + "livePaint.fill", + &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "samePaint", "match": serde_json::to_value(&blue).unwrap()}), + ) + .unwrap(); + assert_eq!(r["count"], 2); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green, red.clone(), red]); +} + +/// A triple-click whose first click makes the Live Paint group from the selected paths matches the +/// fill the face was made with. +#[test] +fn live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill() { + let mut s = session(); + let ids: Vec = [0.0, 100.0, 200.0].iter().map(|&x| rect(&mut s, x, 0.0, 50.0, 50.0)).collect(); + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + for (id, c) in ids.iter().zip([red, red, blue]) { + set_fill(&mut s, *id, c); + } + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [ + PointerKind::Down, + PointerKind::Up, + PointerKind::Down, + PointerKind::Up, + PointerKind::DoubleClick, + PointerKind::Down, + PointerKind::Up, + PointerKind::TripleClick, + ] { + s.pointer(&PointerEvent::new(kind, 25.0, 25.0), v).unwrap(); + } + let g = vectorcraft_tools::builder::live_paint_at(&s.doc().unwrap().doc, Point::new(25.0, 25.0)).expect("the clicks made a group"); + let faces = faces_at(&s, g, &[(25.0, 25.0), (125.0, 25.0), (225.0, 25.0)]); + assert_eq!(faces.iter().map(|f| fill_of(&s, *f)).collect::>(), [green.clone(), green, Paint::solid(blue)]); +} + +/// A double-click on an edge paints the edges joined to it that have its stroke, and a +/// triple-click every edge of the group with that stroke; an edge with another stroke is left. +#[test] +fn live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 200.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let joined = edges_in(&s, g, Rect::new(0.0, 0.0, 100.0, 50.0)); + let apart = edges_in(&s, g, Rect::new(200.0, 0.0, 250.0, 50.0)); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + assert!(!apart.is_empty() && !middle.is_empty()); + paint_parts(&mut s, &joined, &black, &[]); + paint_parts(&mut s, &apart, &black, &[]); + paint_parts(&mut s, &middle, &red, &[]); + let stroke = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).appearance.stroke_paint()).collect::>(); + let outer: Vec = joined.iter().copied().filter(|e| !middle.contains(e)).collect(); + let r = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "width": 3, "scope": "connected"})).unwrap(); + assert_eq!(r["count"], outer.len()); + assert!(stroke(&s, &outer).iter().all(|p| *p == green)); + assert!(outer.iter().all(|e| node(&s, *e).appearance.stroke_width() == 3.0)); + assert!(stroke(&s, &middle).iter().all(|p| *p == red), "another stroke is left"); + assert!(stroke(&s, &apart).iter().all(|p| *p == black), "edges not joined are left"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], outer.len() + apart.len()); + assert!(stroke(&s, &outer).iter().chain(&stroke(&s, &apart)).all(|p| *p == green)); + assert!(stroke(&s, &middle).iter().all(|p| *p == red)); + // A hidden edge is not painted. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.setProps", &json!({"ids": [apart[0].0], "visible": false})).unwrap(); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], outer.len() + apart.len() - 1); + assert_eq!(node(&s, apart[0]).appearance.stroke_paint(), black); +} + +/// A `scope` that is not one of the three, a `match` that is not a paint or is given with a scope +/// that compares none, and a scope with a drag's points are refused; nothing changes. +#[test] +fn live_paint_scope_and_match_are_checked() { + let mut s = session(); + let g = live(&mut s); + let none = serde_json::to_value(Paint::None).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let depth = s.doc().unwrap().history.undo.len(); + for (cmd, p) in [ + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "all"})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": 2})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "match": none})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "connected", "match": none})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "samePaint", "match": "red"})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "connected", "points": [[75, 50], [125, 50]]})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "Connected"})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "one", "match": none})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "match": {"type": "plaid"}})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + // "one" and null are a click's scope. + for scope in [json!("one"), Value::Null] { + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "scope": scope})).unwrap(); + assert_eq!(r["count"], 1); + } +} + #[test] fn live_paint_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ae0c55ac76..8c560a4731 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -650,7 +650,7 @@ impl Session { match ev.kind { PointerKind::Down | PointerKind::Drag => self.pointer_held = true, PointerKind::Up | PointerKind::Move => self.pointer_held = false, - PointerKind::DoubleClick => {} + PointerKind::DoubleClick | PointerKind::TripleClick => {} } match ev.kind { PointerKind::Down => { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 69c7b5ba0e..8bd8afc2e5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -81,6 +81,7 @@ pub(crate) fn pointer_kind(k: &str) -> Option { "up" => PointerKind::Up, "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, + "tripleclick" => PointerKind::TripleClick, _ => return None, }) } @@ -238,7 +239,7 @@ impl Headless { .map(|(e, backquote)| { let mods = packet_mods(e)?.unwrap_or(base); let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick|tripleclick)"))?; PointerEvent::json_check(e)?; if s(e, "space") == Some("screen") { return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6529cf175d..cd34382408 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -16,6 +16,7 @@ mod gradient; mod limits; mod links; mod liquify; +mod livepaint; mod pen; mod persp; mod place; diff --git a/crates/mcp/src/tests/livepaint.rs b/crates/mcp/src/tests/livepaint.rs new file mode 100644 index 0000000000..f3952a90f6 --- /dev/null +++ b/crates/mcp/src/tests/livepaint.rs @@ -0,0 +1,49 @@ +//! The Live Paint Bucket through MCP: `pointer_gesture` sends a triple-click as `tripleclick`. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + ok(h, "run_command", json!({"command": command, "params": params})) +} + +/// The fill of each face of Live Paint group `g`, left to right. +fn fills(h: &mut Headless, g: &Value) -> Vec { + let info = run(h, "livePaint.info", json!({"group": g})); + let mut faces = info["faces"].as_array().unwrap().clone(); + faces.sort_by(|a, b| a["bounds"][0].as_f64().unwrap().total_cmp(&b["bounds"][0].as_f64().unwrap())); + faces.iter().map(|f| f["fill"].clone()).collect() +} + +#[test] +fn a_triple_click_fills_every_face_of_the_same_fill() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + let a = run(&mut h, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let b = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let g = run(&mut h, "livePaint.make", json!({"ids": [a, b]}))["id"].clone(); + for x in [25, 125] { + run(&mut h, "livePaint.fill", json!({"group": g, "point": [x, 25], "color": "#ff0000"})); + } + let red = fills(&mut h, &g); + assert_eq!(red[0], red[1]); + // With nothing selected, the current fill changes and the group keeps its faces' fills. + run(&mut h, "select.none", json!({})); + run(&mut h, "paint.setFill", json!({"color": "#00ff00"})); + assert_eq!(fills(&mut h, &g), red); + let ev = |kind: &str| json!({"kind": kind, "x": 25, "y": 25}); + let clicks = [ev("down"), ev("up"), ev("down"), ev("up"), ev("doubleclick"), ev("down"), ev("up")]; + ok(&mut h, "pointer_gesture", json!({"tool": "livePaintBucket", "events": clicks})); + let after_two = fills(&mut h, &g); + assert_eq!(after_two[1], red[1], "the face apart is left by the clicks and the double-click"); + assert_ne!(after_two[0], red[0]); + ok(&mut h, "pointer_gesture", json!({"events": [ev("tripleclick")]})); + assert_eq!(fills(&mut h, &g), [after_two[0].clone(), after_two[0].clone()], "both faces were red"); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index d4d69c945f..62c87f24b4 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -205,7 +205,7 @@ pub fn tool_definitions() -> Vec { "type": "array", "minItems": 1, "items": obj(json!({ - "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]}, + "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick", "tripleclick"]}, "x": num("Document x (pt)"), "y": num("Document y (pt)"), "mods": mods_schema(), diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index b1c5af70bb..af8a95e900 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -121,7 +121,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result vec![], } } + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/extend_path.rs b/crates/tools/src/addons/extend_path.rs index d1f54861ca..2269396f90 100644 --- a/crates/tools/src/addons/extend_path.rs +++ b/crates/tools/src/addons/extend_path.rs @@ -131,7 +131,7 @@ impl Tool for ExtendPathTool { // Back where it started: nothing to keep. if length == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] } } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/parametric_shape.rs b/crates/tools/src/addons/parametric_shape.rs index d16cb63935..61c2b416a0 100644 --- a/crates/tools/src/addons/parametric_shape.rs +++ b/crates/tools/src/addons/parametric_shape.rs @@ -301,7 +301,7 @@ impl Tool for ParametricShapeTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], PointerKind::Down => { let tol = cx.point_tol(); let grip = selected(cx).and_then(|(id, s)| { diff --git a/crates/tools/src/addons/slide_anchor.rs b/crates/tools/src/addons/slide_anchor.rs index e24db4c50d..0bec99cf35 100644 --- a/crates/tools/src/addons/slide_anchor.rs +++ b/crates/tools/src/addons/slide_anchor.rs @@ -96,7 +96,7 @@ impl Tool for SlideAnchorTool { // Back where it started: nothing to keep. if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] } } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index cd72f1dd40..c25ab8f670 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -527,6 +527,7 @@ impl Tool for SmartPenTool { } _ => vec![], }, + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e2d6c9f77e..5b6bb3a5b8 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,10 +1,11 @@ use super::cache::RegionCache; -use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at}; +use super::lookup::{is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, node_outline, visible_edge_near, visible_face_at}; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains, }; +use vectorcraft_color::Paint; // ---------- Live Paint Bucket ---------- @@ -171,6 +172,14 @@ enum LpPaint { /// click paints nothing. With Highlight off, the face or edge under the pointer is not /// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints /// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper. +/// +/// A double-click on a face fills it and the faces reached from it across edges that draw no +/// stroke (`scope: "connected"`); on an edge it paints the edges joined to it that have its stroke. +/// A triple-click on a face fills every face of the group with the face's fill, and on an edge it +/// paints every edge of the group with the edge's stroke (`scope: "samePaint"`). Each click of a +/// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared +/// is the one the face or edge had before the first of its clicks. Shift and the options choose a +/// face or an edge as for a click; with Alt held a double-click or triple-click paints nothing. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -178,6 +187,9 @@ pub struct LivePaintBucketTool { at: Option<(Point, bool, bool)>, /// The fill command and polyline previewed from the press's snapshot. fill_drag: Option<(Value, Vec>)>, + /// The last [`LP_PRESSES`] presses, oldest first: the face or edge each one painted, with its + /// paint before that press, or None for a press that painted no face or edge. + presses: Vec>, fills: bool, strokes: bool, highlight: bool, @@ -185,6 +197,32 @@ pub struct LivePaintBucketTool { width: f64, } +/// The face or edge a Live Paint Bucket press paints. +#[derive(Clone, Debug, PartialEq)] +struct LpPress { + /// The face or edge; None for the face of the Live Paint group the press makes. + part: Option, + /// Its fill (a face) or stroke paint (an edge) before the press. + before: Paint, +} + +/// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the +/// front-most of their paths whose outline holds `p`, else none. +fn made_face_fill(doc: &Document, roots: &[NodeId], p: Point) -> Paint { + let mut leaves = vec![]; + for n in roots.iter().filter_map(|id| doc.node(*id)) { + leaf_shapes(n, &mut leaves); + } + leaves + .iter() + .rev() + .find(|l| node_outline(l).is_some_and(|(path, rule)| !path.is_empty() && path_contains(&path, rule, p))) + .map_or(Paint::None, |l| l.appearance.fill_paint()) +} + +/// How many presses the Live Paint Bucket keeps: the three of a triple-click. +const LP_PRESSES: usize = 3; + /// Maximum points in one Live Paint fill path chunk. pub const LP_FILL_CHUNK_POINTS: usize = 4096; @@ -195,6 +233,7 @@ impl Default for LivePaintBucketTool { hover: LpHover::None, at: None, fill_drag: None, + presses: Vec::new(), fills: true, strokes: false, highlight: true, @@ -246,6 +285,62 @@ impl LivePaintBucketTool { } } + /// Note a press: what it paints, None when it paints no face or edge. + fn note_press(&mut self, press: Option) { + if self.presses.len() == LP_PRESSES { + self.presses.remove(0); + } + self.presses.push(press); + } + + /// Note a press that paints face or edge (`edge`) `n`. + fn note_part(&mut self, n: &Node, edge: bool) { + let before = if edge { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; + self.note_press(Some(LpPress { part: Some(n.id), before })); + } + + /// The paint `part` had before the first press of a click sequence of `clicks` presses, when + /// those presses all painted `part` (the first may have made the group `part` is in); else + /// `now`. + fn before(&self, part: NodeId, clicks: usize, now: Paint) -> Paint { + let Some(run) = self.presses.len().checked_sub(clicks).and_then(|from| self.presses.get(from..)) else { return now }; + let on_part = |(i, q): (usize, &Option)| q.as_ref().is_some_and(|q| q.part == Some(part) || (i == 0 && q.part.is_none())); + match run.first() { + Some(Some(first)) if run.iter().enumerate().all(on_part) => first.before.clone(), + _ => now, + } + } + + /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at + /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. + fn multi_click(&self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { + if mods.alt { + return vec![]; + } + let point = json!([p.x, p.y]); + let paint = self.paints(mods.shift); + if matches!(paint, LpPaint::Strokes | LpPaint::Both) + && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) + { + let mut v = json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0), "scope": scope }); + if let Ok(like) = serde_json::to_value(self.before(e.id, clicks, e.appearance.stroke_paint())) { + v["match"] = like; + } + return vec![Action::Exec("livePaint.strokeEdge".into(), v)]; + } + if !matches!(paint, LpPaint::Fills | LpPaint::Both) { + return vec![]; + } + let Some((g, f)) = live_paint_at(cx.doc, p).and_then(|g| Some((g, visible_face_at(cx.doc.node(g)?, p)?))) else { return vec![] }; + let mut v = json!({ "group": g.0, "point": point, "scope": scope }); + if scope == "samePaint" + && let Ok(like) = serde_json::to_value(self.before(f.id, clicks, f.appearance.fill_paint())) + { + v["match"] = like; + } + vec![Action::Exec("livePaint.fill".into(), v)] + } + /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's /// map is still made, for the cursor ([`Self::paints_at`]). @@ -335,6 +430,7 @@ impl Tool for LivePaintBucketTool { PointerKind::Down => { self.hover = LpHover::None; if ev.mods.alt { + self.note_press(None); if ev.mods.shift { return crate::xform::eyedropper::sample_color_at(cx, p).map_or_else(Vec::new, |color| { vec![Action::Exec("paint.sampleColor".into(), json!({ "color": crate::params::color_json(&color), "ids": [] }))] @@ -359,20 +455,28 @@ impl Tool for LivePaintBucketTool { let point = json!([p.x, p.y]); let paint = self.paints(ev.mods.shift); if matches!(paint, LpPaint::Strokes | LpPaint::Both) - && let Some((g, Some(_))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) + && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { + self.note_part(e, true); return vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]; } if !matches!(paint, LpPaint::Fills | LpPaint::Both) { + self.note_press(None); return vec![]; } if let Some(g) = live_paint_at(cx.doc, p) { + match cx.doc.node(g).and_then(|n| visible_face_at(n, p)) { + Some(f) => self.note_part(f, false), + None => self.note_press(None), + } return self.begin_fill(json!({ "group": g.0, "point": point }), p); } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); + self.note_press(Some(LpPress { part: None, before: made_face_fill(cx.doc, &roots, p) })); return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } + self.note_press(None); vec![] } PointerKind::Drag => self.drag_fill(p), @@ -383,10 +487,15 @@ impl Tool for LivePaintBucketTool { } acts } - _ => vec![], + PointerKind::DoubleClick => self.multi_click(cx, p, ev.mods, "connected", 2), + PointerKind::TripleClick => self.multi_click(cx, p, ev.mods, "samePaint", 3), } } + fn history_moved(&mut self) { + self.presses.clear(); + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] } } @@ -400,6 +509,7 @@ impl Tool for LivePaintBucketTool { } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.presses.clear(); if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 298e6a1907..e6bfbee7f4 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1087,6 +1087,78 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { assert_eq!(id, "livePaint.strokeEdge"); } +/// A double-click runs a click's command with `scope: "connected"` and a triple-click with +/// `"samePaint"`, matching the paint the face or edge had before the first click there. +#[test] +fn bucket_double_and_triple_click_widen_what_a_click_paints() { + let (d, gid, f2, e) = live_group_doc(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let mut painted = d.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(red.clone()); + painted.node_mut(e).unwrap().appearance.set_stroke(red.clone()); + let s = Selection::default(); + let p = paint(); + let (cx, later) = (cx(&d, &s, &p), cx(&painted, &s, &p)); + let face_before = serde_json::to_value(d.node(f2).unwrap().appearance.fill_paint()).unwrap(); + let edge_before = serde_json::to_value(d.node(e).unwrap().appearance.stroke_paint()).unwrap(); + let red_json = serde_json::to_value(&red).unwrap(); + assert_ne!(face_before, red_json); + assert_ne!(edge_before, red_json); + let fill = |scope: &str, like: Option<&Value>| { + let mut v = json!({ "group": gid.0, "point": [75.0, 25.0], "scope": scope }); + if let Some(like) = like { + v["match"] = like.clone(); + } + vec![Action::Exec("livePaint.fill".into(), v)] + }; + let click = |t: &mut LivePaintBucketTool, cx: &ToolContext, x: f64, m: Mods| { + t.pointer(cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + t.pointer(cx, &ev(PointerKind::Up, x, 25.0).with_mods(m)); + }; + let mut t = LivePaintBucketTool::default(); + // The clicks paint the face as they arrive; the triple-click matches the fill from before them. + click(&mut t, &cx, 75.0, Mods::default()); + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::DoubleClick, 75.0, 25.0)), fill("connected", None)); + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&face_before))); + // A new click there starts again from the fill it finds. + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&red_json))); + // A click before the triple-click's own three is not part of it. + let mut t = LivePaintBucketTool::default(); + click(&mut t, &cx, 75.0, Mods::default()); + for _ in 0..3 { + click(&mut t, &later, 75.0, Mods::default()); + } + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&red_json))); + // Shift: the edge, matching its stroke from before the first click. + let shift = Mods { shift: true, ..Default::default() }; + let stroke = |scope: &str, like: &Value| { + vec![Action::Exec( + "livePaint.strokeEdge".into(), + json!({ "group": gid.0, "point": [51.0, 25.0], "tolerance": 4.0, "scope": scope, "match": like }), + )] + }; + click(&mut t, &cx, 51.0, shift); + click(&mut t, &later, 51.0, shift); + assert_eq!(t.pointer(&later, &ev(PointerKind::DoubleClick, 51.0, 25.0).with_mods(shift)), stroke("connected", &edge_before)); + click(&mut t, &later, 51.0, shift); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 51.0, 25.0).with_mods(shift)), stroke("samePaint", &edge_before)); + // With Alt held, or with both options off, a double-click or triple-click paints nothing. + let alt = Mods { alt: true, ..Default::default() }; + for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(alt)).is_empty(), "{k:?}"); + } + t.set_option("paintFills", &json!(false)); + for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0)).is_empty(), "{k:?}"); + } + // Outside every face, nothing is painted. + t.set_option("paintFills", &json!(true)); + assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 400.0, 400.0)).is_empty()); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index ff716bf171..0245f75112 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -169,6 +169,7 @@ impl Tool for CropImageTool { PointerKind::Up => self.drag = None, PointerKind::DoubleClick => return self.apply(cx), PointerKind::Move => self.last = ev.pos, + PointerKind::TripleClick => {} } vec![] } diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index f141378382..2ca6ec0631 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -422,7 +422,7 @@ impl Tool for PuppetWarpTool { } None => vec![], }, - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } /// Delete/Backspace remove the selected pins; with none selected they are the shortcut's diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index e7050d3a79..4ebbac4981 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -300,6 +300,7 @@ impl Tool for WidthTool { self.hover = Self::spot_at(cx, p); vec![] } + PointerKind::TripleClick => vec![], PointerKind::DoubleClick => { let Some(spot) = Self::spot_at(cx, p) else { return vec![] }; let Some((w, _)) = Self::hit(cx, &spot, p) else { diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 02087d997a..06ad1c1230 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -653,6 +653,7 @@ impl Tool for CurvatureTool { self.pts[i].1 = !self.pts[i].1; vec![Action::Exec("path.curvature".into(), self.params())] } + PointerKind::TripleClick => vec![], } } fn notify(&mut self, cx: &ToolContext, what: &str) { diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index 9b53d2404c..5362147b97 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -29,6 +29,9 @@ pub enum PointerKind { /// Moved with no button (hover). Move, DoubleClick, + /// The third click of a triple-click, after its press and release (the second click's + /// release sends [`PointerKind::DoubleClick`]). + TripleClick, } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index a0ab57ae87..24a21d08ca 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -449,7 +449,7 @@ impl Tool for PenTool { } vec![Action::Commit] } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { diff --git a/crates/tools/src/printtiling.rs b/crates/tools/src/printtiling.rs index b4e0e06d0c..a28382daf8 100644 --- a/crates/tools/src/printtiling.rs +++ b/crates/tools/src/printtiling.rs @@ -68,7 +68,7 @@ impl Tool for PrintTilingTool { self.drag = None; vec![Action::Exec("print.tiling.set".into(), json!({ "reset": true }))] } - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index dd561bc616..e18754f0ca 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -113,6 +113,7 @@ impl Tool for TypeTool { } vec![] } + PointerKind::TripleClick => vec![], PointerKind::Move => { // The type being edited is no target: its guides would only point at itself. self.snap.hover(cx, ev.pos, self.editing.as_slice(), None); diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index 172487c197..9c16663492 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -386,7 +386,7 @@ impl Tool for TouchTypeTool { Some(d) if d.began => vec![Action::Commit], _ => vec![], }, - PointerKind::DoubleClick | PointerKind::Move => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick | PointerKind::Move => vec![], } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 9c8881b580..44a31b84eb 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -560,7 +560,7 @@ impl Tool for GradientTool { let at = a.stop_point(cx, i).unwrap_or(ev.pos); vec![Self::select_stop(i), Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))] } - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 263c0ea5e4..395b235e4a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -272,6 +272,7 @@ impl Tool for TransformTool { None => vec![], } } + PointerKind::TripleClick => vec![], PointerKind::Move => { if self.drag.is_none() { self.hover = if cx.selection.is_empty() { vec![] } else { snap_pick(cx, p).1 }; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 879f86dd1e..6e9bb90ff4 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -179,6 +179,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo { double_click(app, ui, &f, p); } + if resp.triple_clicked() + && !f.modal + && let Some(p) = at + { + triple_click(app, ui, &f, p); + } if drag.is_some() || pointer.is_moving() || mods_changed { ui.ctx().request_repaint(); } @@ -589,6 +595,23 @@ fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { } } +/// A triple-click at `p`: the tool gets it, unless it is on an artboard's name. +fn triple_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { m, space, xf, view, .. } = *f; + if artboard_name_at(app, ui, &xf, p).is_some() { + return; + } + let ev = PointerEvent { + kind: PointerKind::TripleClick, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + pen: None, + }; + dispatch(app, &ev, view); +} + /// Where `button` went up in this frame, if it did. fn release_pos(ui: &Ui, button: egui::PointerButton) -> Option { ui.input(|i| { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1f7f715fc4..50499af278 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.menu.list`: flattened menu tree //! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened //! - `ui.tool.select {tool}`, `ui.tool.list` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick|tripleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: //! drive the active tool through the same path as the mouse, after the synthetic input sent //! before it was applied //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input @@ -1799,7 +1799,7 @@ fn rotate_view_pointer( PointerKind::Up => { app.control_rotate_press = None; } - PointerKind::Move | PointerKind::DoubleClick => {} + PointerKind::Move | PointerKind::DoubleClick | PointerKind::TripleClick => {} } Ok(()) } @@ -1867,6 +1867,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "up" => PointerKind::Up, "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, + "tripleclick" => PointerKind::TripleClick, other => return Err(format!("unknown pointer kind `{other}`")), }; PointerEvent::json_check(e)?; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c2d4c39b06..6f9a0ebe32 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -96,7 +96,7 @@ the two can still win that race. | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | -| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | +| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick|tripleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse (`doubleclick` follows the second click's `up`, and `tripleclick` the third's): `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..fa14520101 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -625,8 +625,8 @@ sampler. | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`, `tripleclick` (`doubleclick` follows the second click's `up`, and `tripleclick` the third's). `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`, `tripleclick` (`doubleclick` follows the second click's `up`, and `tripleclick` the third's). `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. Polygons and stars take `rotation` in degrees clockwise (0: a corner or point straight up). `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | @@ -3463,6 +3463,22 @@ already walks Image Trace. Short agent entry points: `livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under the pointer is not outlined. + A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with + `scope: "samePaint"`; Alt held runs nothing. On a face, `connected` fills the face and every face reached from it + across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap + that Gap Detection closes and the group's outside stop it. `samePaint` fills every face + of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the + edges joined to it end to end whose stroke paint is `match`, and `samePaint` paints every edge of the group with + that stroke paint; the stroke's weight is not compared. Each click of a double-click or triple-click runs as it + arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's + two clicks or the triple-click's three, so the clicks before the last do not change what it matches. Hidden faces and edges are not painted. These rules + for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: + https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. + `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or + `"samePaint"`; any other value is an error and changes nothing. `match` is a paint in the model JSON that + `document.node {id}` writes for an appearance's paints (`{"type": "none"}` for none), taken only with a scope that compares paints, and + defaults to the face's fill or the edge's stroke paint. `livePaint.fill` refuses a `scope` other than `"one"` with + `points`. Both replies add `count`, the number of faces or edges painted. `livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as `livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..342b853c02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1406,6 +1406,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::bucket_double_and_triple_click_widen_what_a_click_paints`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From d84e81aec4755480eba5c241eb0f894bd5a26896 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:15:22 -0800 Subject: [PATCH 06/45] Live Paint: params text names its parameter on every line livePaint.fill's description listed "not with points" as its own entry, which the params parser reads as a parameter name, so every_description_parses failed. The note now sits inside the samePaint value's parentheses and says that "connected" and "samePaint" are refused with points, as lp_fill does. --- crates/engine/src/cmd/buildcmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 790d54214b..e0f952e754 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match), not with points, match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face, count (faces filled)}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face, count (faces filled)}", has_doc, lp_fill ), From 52aec70e19839251fca20818b8bfeb374f4108a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:05:06 -0800 Subject: [PATCH 07/45] Live Paint: double-click floods along split edges, triple-click on new art skips open paths - A face outline segment that runs along several edge segments (the face has no corner where a crossing line splits the edge) now joins the face to every edge segment it runs along, not only the one under its middle, so a double-click flood no longer stops there. - The first click of a triple-click on selected paths, which makes the Live Paint group, takes the fill to match from the paths' closed subpaths only, as the group's faces do; an open path in front no longer gives the wrong fill. --- crates/engine/src/cmd/buildcmds/live_paint.rs | 74 +++++++++++++------ crates/engine/src/tests/build.rs | 30 ++++++++ crates/tools/src/builder/live_paint.rs | 14 ++-- crates/tools/src/builder/tests.rs | 38 ++++++++++ 4 files changed, 128 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index faeaa20cbc..073b0f8bd5 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -330,12 +330,13 @@ fn lp_segments(pd: &PathData) -> Vec { /// The faces of Live Paint group `group` that a flood from face `start` reaches: `start`, and each /// face that shares with a face already reached an edge that is hidden or draws no stroke /// ([`unstroked`]). Hidden faces are not reached and do not pass the flood on. A face runs along an -/// edge where a segment of its outline has its middle within [`LP_JOIN_TOL`] of one of the edge's -/// segments, over the part of that segment between its outline segment's ends; two faces share the -/// edge where those parts overlap along more than [`LP_JOIN_TOL`], so two faces that a Gap -/// Detection gap parts on the same side of an edge, whose parts only touch, do not. +/// edge segment over the part of it between the points nearest the ends of one of the face's +/// outline segments, when that part lies within [`LP_JOIN_TOL`] of the outline segment; one outline +/// segment can run along several edge segments. Two faces share the edge where those parts overlap +/// along more than [`LP_JOIN_TOL`], so two faces that a Gap Detection gap parts on the same side of +/// an edge, whose parts only touch, do not. fn flood_faces(group: &Node, start: NodeId) -> Vec { - use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest}; // Each edge segment, and whether a flood crosses it. let edges: Vec<(kurbo::CubicBez, bool)> = b::edges(group) .into_iter() @@ -351,16 +352,13 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec { } // The faces along each edge segment a flood crosses, each with the part of the segment it runs // along (from and to, in the segment's own parameter). A face outline segment is usually an - // edge segment, with the same middle; one that is not (a piece of an edge segment where a Gap - // Detection line meets it, or the line itself) is measured against the edge segments near it. + // edge segment, with the same ends; one that is not (a piece of an edge segment where a Gap + // Detection line meets it, the line itself, or a run along several edge segments where the + // face has no corner at the points that split them) is measured against the edge segments + // near it. let mut along: std::collections::HashMap> = std::collections::HashMap::new(); - let mut add = |k: usize, id: NodeId, piece: &kurbo::CubicBez| { - if let Some((s, true)) = edges.get(k) { - let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t); - along.entry(k).or_default().push((id, a.min(c), a.max(c))); - } - }; let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![]; + let near = |p: Point, q: Point| (p - q).hypot() <= LP_JOIN_TOL; for f in b::faces(group).into_iter().filter(|f| f.visible) { for piece in f.path_data().map(lp_segments).unwrap_or_default() { let m = piece.eval(0.5); @@ -368,9 +366,18 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec { let same = (-1..=1) .flat_map(|dx| (-1..=1).map(move |dy| (x.saturating_add(dx), y.saturating_add(dy)))) .flat_map(|c| mids.get(&c).map(Vec::as_slice).unwrap_or_default()) - .find(|(q, _)| (*q - m).hypot() <= LP_JOIN_TOL); + .find(|&&(q, k)| { + near(q, m) + && edges + .get(k) + .is_some_and(|(s, _)| (near(s.p0, piece.p0) && near(s.p3, piece.p3)) || (near(s.p0, piece.p3) && near(s.p3, piece.p0))) + }); match same { - Some(&(_, k)) => add(k, f.id, &piece), + Some(&(_, k)) => { + if edges.get(k).is_some_and(|e| e.1) { + along.entry(k).or_default().push((f.id, 0.0, 1.0)); + } + } None => pieces.push((piece, f.id)), } } @@ -378,10 +385,15 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec { if !pieces.is_empty() { let grid = LpEdgeGrid::new(&edges); for (piece, id) in pieces { - let m = piece.eval(0.5); - for k in grid.near(m) { - if edges.get(k).is_some_and(|(s, cross)| *cross && s.nearest(m, 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) { - add(k, id, &piece); + for k in grid.near_rect(piece.bounding_box()) { + let Some((s, true)) = edges.get(k) else { continue }; + let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t); + let (from, to) = (a.min(c), a.max(c)); + // The part of the edge segment between the piece's ends runs along the piece. + let on_piece = + [0.25, 0.5, 0.75].iter().all(|u| piece.nearest(s.eval(from + (to - from) * u), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL); + if from < to && on_piece { + along.entry(k).or_default().push((id, from, to)); } } } @@ -464,9 +476,27 @@ impl LpEdgeGrid { (at(p.x, self.origin.x), at(p.y, self.origin.y)) } - /// The segments whose widened bounds may hold `p`. - fn near(&self, p: Point) -> impl Iterator + '_ { - self.wide.iter().chain(self.cells.get(&self.cell_of(p)).into_iter().flatten()).copied() + /// The segments whose widened bounds may meet `r`, each once. + fn near_rect(&self, r: Rect) -> Vec { + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1))); + let mut out = self.wide.clone(); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > self.cells.len() as i64 { + // More cells than the grid fills: walk the filled ones. + for (&(x, y), ks) in &self.cells { + if (x0..=x1).contains(&x) && (y0..=y1).contains(&y) { + out.extend_from_slice(ks); + } + } + } else { + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + } + out.sort_unstable(); + out.dedup(); + out } } diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index c0fbd5efe0..582371ca6f 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2715,6 +2715,36 @@ fn live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps() { assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the gap parts the two faces along the bottom"); } +/// The flood crosses an edge into each face along it when a face's outline runs along the edge in +/// one segment and the edge is split where another line meets it. +#[test] +fn live_paint_bucket_double_click_floods_along_edges_split_at_a_junction() { + // A square with a line across its middle, and a line from the top at x = 30 that crosses it and + // stops 20 pt below. The bottom face's outline runs along the middle line in one segment, from + // x = 0 to 100; the middle line's edges are split at x = 30. + let mut s = session(); + let mut ids = vec![rect(&mut s, 0.0, 0.0, 100.0, 100.0).0]; + for anchors in [json!([{"x": 0, "y": 50}, {"x": 100, "y": 50}]), json!([{"x": 30, "y": 0}, {"x": 30, "y": 70}])] { + ids.push(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap()); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let [left, right, bottom] = faces_at(&s, g, &[(15.0, 25.0), (65.0, 25.0), (65.0, 75.0)])[..] else { panic!("three faces") }; + assert!(left != right && right != bottom && left != bottom); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let down = edges_in(&s, g, Rect::new(30.0, 0.0, 30.0, 70.0)); + assert!(!down.is_empty() && down.len() < all.len()); + paint_parts(&mut s, &down, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [65, 75], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "from the bottom face to both faces above the line"); + assert_eq!([left, right, bottom].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [15, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "from the upper left face down to the bottom face and up again"); +} + /// A triple-click on a face fills every face of the group with that face's fill, joined to it or /// not. Through the tool, the fill compared is the face's fill before the first click. #[test] diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 5b6bb3a5b8..a6a6af0915 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -207,17 +207,19 @@ struct LpPress { } /// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the -/// front-most of their paths whose outline holds `p`, else none. +/// front-most of their paths whose closed subpaths hold `p`, else none. Open subpaths bound no face. fn made_face_fill(doc: &Document, roots: &[NodeId], p: Point) -> Paint { let mut leaves = vec![]; for n in roots.iter().filter_map(|id| doc.node(*id)) { leaf_shapes(n, &mut leaves); } - leaves - .iter() - .rev() - .find(|l| node_outline(l).is_some_and(|(path, rule)| !path.is_empty() && path_contains(&path, rule, p))) - .map_or(Paint::None, |l| l.appearance.fill_paint()) + let holds = |l: &Node| { + node_outline(l).is_some_and(|(path, rule)| { + let closed = vectorcraft_geom::PathData::new(path.subpaths.into_iter().filter(|sp| sp.closed && sp.anchors.len() >= 2).collect()); + !closed.is_empty() && path_contains(&closed, rule, p) + }) + }; + leaves.iter().rev().find(|l| holds(l)).map_or(Paint::None, |l| l.appearance.fill_paint()) } /// How many presses the Live Paint Bucket keeps: the three of a triple-click. diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index e6bfbee7f4..80a2dbd7a4 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1159,6 +1159,44 @@ fn bucket_double_and_triple_click_widen_what_a_click_paints() { assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 400.0, 400.0)).is_empty()); } +/// A triple-click whose first click makes the Live Paint group matches the fill of the face the +/// group makes there: an open path in front, whose outline bounds no face, does not give it. +#[test] +fn bucket_triple_click_on_a_made_group_skips_open_paths() { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::{PathData, SubPath}; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let back = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(red.clone()); + d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look)).unwrap(); + // An open U around (75, 25), filled blue. + let u = [Point::new(60.0, 5.0), Point::new(60.0, 45.0), Point::new(90.0, 45.0), Point::new(90.0, 5.0)]; + let front = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(blue); + d.insert(Some(l), 1, Node::path(front, PathData::single(SubPath::polyline(&u, false)), look)).unwrap(); + let mut s = Selection::default(); + s.set([back, front]); + let p = paint(); + let (made, ..) = live_group_doc(); + let none = Selection::default(); + let (cx, later) = (cx(&d, &s, &p), cx(&made, &none, &p)); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); + for _ in 0..2 { + t.pointer(&later, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(&later, &ev(PointerKind::Up, 75.0, 25.0)); + } + let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["match"], serde_json::to_value(&red).unwrap()); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); From 4dd3809f84aa85dc35e84c42822b07fc384172b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:43:37 -0800 Subject: [PATCH 08/45] Live Paint: triple-click test checks the face the planar map makes The test for a triple-click whose first click makes the group now also builds the planar map from the same two paths and checks that the face at the click takes its fill from the square. The doc comment of the double-click flood names the three points it checks along each edge segment. --- crates/engine/src/cmd/buildcmds/live_paint.rs | 5 +++-- crates/tools/src/builder/tests.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 073b0f8bd5..b049f59f0c 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -331,8 +331,9 @@ fn lp_segments(pd: &PathData) -> Vec { /// face that shares with a face already reached an edge that is hidden or draws no stroke /// ([`unstroked`]). Hidden faces are not reached and do not pass the flood on. A face runs along an /// edge segment over the part of it between the points nearest the ends of one of the face's -/// outline segments, when that part lies within [`LP_JOIN_TOL`] of the outline segment; one outline -/// segment can run along several edge segments. Two faces share the edge where those parts overlap +/// outline segments, when that part's points at a quarter, half and three quarters of its +/// parameter range lie within [`LP_JOIN_TOL`] of the outline segment; one outline segment can run +/// along several edge segments. Two faces share the edge where those parts overlap /// along more than [`LP_JOIN_TOL`], so two faces that a Gap Detection gap parts on the same side of /// an edge, whose parts only touch, do not. fn flood_faces(group: &Node, start: NodeId) -> Vec { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 80a2dbd7a4..1e22c67b26 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1178,6 +1178,15 @@ fn bucket_triple_click_on_a_made_group_skips_open_paths() { let mut look = Appearance::default_art(); look.set_fill(blue); d.insert(Some(l), 1, Node::path(front, PathData::single(SubPath::polyline(&u, false)), look)).unwrap(); + // The face the planar map makes there takes its fill from the square, as the group's would. + let shapes: Vec = [back, front] + .iter() + .enumerate() + .filter_map(|(k, id)| node_outline(d.node(*id)?).map(|(path, rule)| vectorcraft_pathops::Shape::new(path, rule, k as u64))) + .collect(); + let (regions, _) = vectorcraft_pathops::live_paint(&shapes); + let face = regions.iter().find(|r| r.contains(Point::new(75.0, 25.0))).expect("a face at (75, 25)"); + assert_eq!(face.top(), Some(0), "the square is the face's top shape"); let mut s = Selection::default(); s.set([back, front]); let p = paint(); From f59381ab59d5cc9072cecc26581484c1a9109abb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 18:19:05 -0800 Subject: [PATCH 09/45] Live Paint: a triple-click matches the paint of the face its last clicks painted A double-click or triple-click compares the paint the face or edge had before the first of its last clicks that painted it. A first click that made the Live Paint group counts only when the face it filled is that face, so clicks that move from one face to another no longer match the paint under the first click. With Space held for the view tools, a press, a double-click or a triple-click with the Live Paint Bucket paints nothing. The ui.pointer row in docs/control-protocol.md escapes the pipes in its parameters cell, so the table keeps three columns. --- crates/tools/src/builder/live_paint.rs | 46 +++++++++++++++--------- crates/tools/src/builder/tests.rs | 50 +++++++++++++++++++++++++- docs/control-protocol.md | 2 +- docs/mcp.md | 5 +-- docs/target-app-parity.md | 2 +- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 8e3dc0d58a..ff59b22333 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -179,7 +179,8 @@ enum LpPaint { /// paints every edge of the group with the edge's stroke (`scope: "samePaint"`). Each click of a /// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared /// is the one the face or edge had before the first of its clicks. Shift and the options choose a -/// face or an edge as for a click; with Alt held a double-click or triple-click paints nothing. +/// face or an edge as for a click; with Alt held a double-click or triple-click paints nothing. With +/// Space held for the view tools, a click, double-click or triple-click paints nothing. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -202,6 +203,8 @@ pub struct LivePaintBucketTool { struct LpPress { /// The face or edge; None for the face of the Live Paint group the press makes. part: Option, + /// Where the press was. + at: Point, /// Its fill (a face) or stroke paint (an edge) before the press. before: Paint, } @@ -295,20 +298,23 @@ impl LivePaintBucketTool { self.presses.push(press); } - /// Note a press that paints face or edge (`edge`) `n`. - fn note_part(&mut self, n: &Node, edge: bool) { + /// Note a press at `at` that paints face or edge (`edge`) `n`. + fn note_part(&mut self, n: &Node, edge: bool, at: Point) { let before = if edge { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; - self.note_press(Some(LpPress { part: Some(n.id), before })); + self.note_press(Some(LpPress { part: Some(n.id), at, before })); } - /// The paint `part` had before the first press of a click sequence of `clicks` presses, when - /// those presses all painted `part` (the first may have made the group `part` is in); else - /// `now`. - fn before(&self, part: NodeId, clicks: usize, now: Paint) -> Paint { + /// The paint `part` had before the first of the last presses of a click sequence of `clicks` + /// presses that all painted `part` (the sequence's first press may have made the group `part` + /// is in, when the face it filled there is `part`); `now` when the last press did not paint it. + fn before(&self, cx: &ToolContext, part: NodeId, clicks: usize, now: Paint) -> Paint { let Some(run) = self.presses.len().checked_sub(clicks).and_then(|from| self.presses.get(from..)) else { return now }; - let on_part = |(i, q): (usize, &Option)| q.as_ref().is_some_and(|q| q.part == Some(part) || (i == 0 && q.part.is_none())); - match run.first() { - Some(Some(first)) if run.iter().enumerate().all(on_part) => first.before.clone(), + let made_part = |q: &LpPress| live_paint_at(cx.doc, q.at).and_then(|g| visible_face_at(cx.doc.node(g)?, q.at)).is_some_and(|f| f.id == part); + let on_part = + |(i, q): (usize, &Option)| q.as_ref().is_some_and(|q| q.part == Some(part) || (i == 0 && q.part.is_none() && made_part(q))); + let from = run.len().saturating_sub(run.iter().enumerate().rev().take_while(|&iq| on_part(iq)).count()); + match run.get(from) { + Some(Some(first)) => first.before.clone(), _ => now, } } @@ -316,7 +322,8 @@ impl LivePaintBucketTool { /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. fn multi_click(&self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { - if mods.alt { + // With Space held the press pans, zooms or rotates the view and does not reach the tool. + if mods.alt || mods.space { return vec![]; } let point = json!([p.x, p.y]); @@ -325,7 +332,7 @@ impl LivePaintBucketTool { && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { let mut v = json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0), "scope": scope }); - if let Ok(like) = serde_json::to_value(self.before(e.id, clicks, e.appearance.stroke_paint())) { + if let Ok(like) = serde_json::to_value(self.before(cx, e.id, clicks, e.appearance.stroke_paint())) { v["match"] = like; } return vec![Action::Exec("livePaint.strokeEdge".into(), v)]; @@ -336,7 +343,7 @@ impl LivePaintBucketTool { let Some((g, f)) = live_paint_at(cx.doc, p).and_then(|g| Some((g, visible_face_at(cx.doc.node(g)?, p)?))) else { return vec![] }; let mut v = json!({ "group": g.0, "point": point, "scope": scope }); if scope == "samePaint" - && let Ok(like) = serde_json::to_value(self.before(f.id, clicks, f.appearance.fill_paint())) + && let Ok(like) = serde_json::to_value(self.before(cx, f.id, clicks, f.appearance.fill_paint())) { v["match"] = like; } @@ -431,6 +438,11 @@ impl Tool for LivePaintBucketTool { } PointerKind::Down => { self.hover = LpHover::None; + // With Space held the press pans, zooms or rotates the view. + if ev.mods.space { + self.note_press(None); + return vec![]; + } if ev.mods.alt { self.note_press(None); if ev.mods.shift { @@ -459,7 +471,7 @@ impl Tool for LivePaintBucketTool { if matches!(paint, LpPaint::Strokes | LpPaint::Both) && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { - self.note_part(e, true); + self.note_part(e, true, p); return vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]; } if !matches!(paint, LpPaint::Fills | LpPaint::Both) { @@ -468,14 +480,14 @@ impl Tool for LivePaintBucketTool { } if let Some(g) = live_paint_at(cx.doc, p) { match cx.doc.node(g).and_then(|n| visible_face_at(n, p)) { - Some(f) => self.note_part(f, false), + Some(f) => self.note_part(f, false, p), None => self.note_press(None), } return self.begin_fill(json!({ "group": g.0, "point": point }), p); } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); - self.note_press(Some(LpPress { part: None, before: made_face_fill(cx.doc, &roots, p) })); + self.note_press(Some(LpPress { part: None, at: p, before: made_face_fill(cx.doc, &roots, p) })); return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } self.note_press(None); diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 1e22c67b26..a0811c5071 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1145,10 +1145,19 @@ fn bucket_double_and_triple_click_widen_what_a_click_paints() { assert_eq!(t.pointer(&later, &ev(PointerKind::DoubleClick, 51.0, 25.0).with_mods(shift)), stroke("connected", &edge_before)); click(&mut t, &later, 51.0, shift); assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 51.0, 25.0).with_mods(shift)), stroke("samePaint", &edge_before)); - // With Alt held, or with both options off, a double-click or triple-click paints nothing. + // With Alt or Space held, or with both options off, a double-click or triple-click paints nothing. let alt = Mods { alt: true, ..Default::default() }; + let space = Mods { space: true, ..Default::default() }; for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(alt)).is_empty(), "{k:?}"); + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(space)).is_empty(), "{k:?} with Space"); + assert!(t.pointer(&cx, &ev(k, 51.0, 25.0).with_mods(Mods { shift: true, ..space })).is_empty(), "{k:?} with Shift and Space"); + } + // A press with Space held paints nothing either. + for m in [space, Mods { shift: true, ..space }] { + for (k, x) in [(PointerKind::Down, 75.0), (PointerKind::Up, 75.0), (PointerKind::Down, 51.0), (PointerKind::Up, 51.0)] { + assert!(t.pointer(&cx, &ev(k, x, 25.0).with_mods(m)).is_empty(), "{k:?} at {x} with {m:?}"); + } } t.set_option("paintFills", &json!(false)); for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { @@ -1206,6 +1215,45 @@ fn bucket_triple_click_on_a_made_group_skips_open_paths() { assert_eq!(v["match"], serde_json::to_value(&red).unwrap()); } +/// A triple-click whose first click makes the Live Paint group on one face and whose other two +/// clicks fill another face matches the fill the second face had before the first click on it, +/// not the fill under the first click or the fill the clicks gave it. +#[test] +fn bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face() { + use vectorcraft_color::{Color, Paint}; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let square = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(red.clone()); + d.insert(Some(l), 0, Node::path(square, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look)).unwrap(); + let mut s = Selection::default(); + s.set([square]); + let p = paint(); + let (made, _, f2, _) = live_group_doc(); + let mut painted = made.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(blue.clone()); + let none = Selection::default(); + let (cx, later, after) = (cx(&d, &s, &p), cx(&made, &none, &p), cx(&painted, &none, &p)); + let mut t = LivePaintBucketTool::default(); + // The first click makes the group and fills the face at (25, 25); the next two fill (75, 25), + // the first of them painting it blue. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Up, 25.0, 25.0)); + for ctx in [&later, &after] { + t.pointer(ctx, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(ctx, &ev(PointerKind::Up, 75.0, 25.0)); + } + let acts = t.pointer(&after, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(id, "livePaint.fill"); + let own = serde_json::to_value(made.node(f2).unwrap().appearance.fill_paint()).unwrap(); + assert_ne!(own, serde_json::to_value(&red).unwrap()); + assert_ne!(own, serde_json::to_value(&blue).unwrap()); + assert_eq!(v["match"], own); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b1d100bc57..2696f2408b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -96,7 +96,7 @@ the two can still win that race. | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | -| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick|tripleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse (`doubleclick` follows the second click's `up`, and `tripleclick` the third's): `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | +| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, space?: "doc"\|"screen", mods?}]}` | drive the active tool exactly like the mouse (`doubleclick` follows the second click's `up`, and `tripleclick` the third's): `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | diff --git a/docs/mcp.md b/docs/mcp.md index dee354486d..9887f22044 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3530,14 +3530,15 @@ already walks Image Trace. Short agent entry points: Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under the pointer is not outlined. A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with - `scope: "samePaint"`; Alt held runs nothing. On a face, `connected` fills the face and every face reached from it + `scope: "samePaint"`; Alt held runs nothing. With Space held (`mods.space`, the view tools), a press, a + double-click or a triple-click runs nothing. On a face, `connected` fills the face and every face reached from it across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap that Gap Detection closes and the group's outside stop it. `samePaint` fills every face of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the edges joined to it end to end whose stroke paint is `match`, and `samePaint` paints every edge of the group with that stroke paint; the stroke's weight is not compared. Each click of a double-click or triple-click runs as it arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's - two clicks or the triple-click's three, so the clicks before the last do not change what it matches. Hidden faces and edges are not painted. These rules + two clicks or the triple-click's three that painted it, so the clicks before the last do not change what it matches. Hidden faces and edges are not painted. These rules for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7064ca45b4..f4a7bb708b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1437,7 +1437,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. - Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::bucket_double_and_triple_click_widen_what_a_click_paints`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face}`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From bc2caaaa37e2ac13e0e88d9264fd600990801c9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:10:40 -0800 Subject: [PATCH 10/45] Live Paint: a triple-click matches the first fill of a face clicked again A triple-click whose clicks land on a face, another face, then the first face again matched the fill the first click gave that face. It now matches the fill the face had before the first click that painted it, as the tool's doc comment and docs/mcp.md say. - With Space held for the view tools, the Live Paint Bucket outlines no face or edge, as a press there paints nothing. - A scoped livePaint.fill or livePaint.strokeEdge where every face or edge reached already has the paint records no undo step; a test covers it, and one where only some faces need the paint. - An MCP test drives the bucket through ui.pointer with mods.space. - The tool's doc comment and the progress line state the rule. --- crates/engine/src/tests/build.rs | 36 +++++++++++++++++++ crates/mcp/src/tests/livepaint.rs | 24 +++++++++++++ crates/tools/src/builder/live_paint.rs | 50 ++++++++++++++------------ crates/tools/src/builder/tests.rs | 45 +++++++++++++++++++++++ docs/mcp.md | 5 +-- docs/target-app-parity.md | 2 +- 6 files changed, 136 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 299dd78a37..89f0877621 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2566,6 +2566,42 @@ fn live_paint_stroke_edge_same_paint_and_width_is_no_op() { assert_eq!(history_state(&s).0, before.1.0 + 1); } +/// With `scope`, faces and edges that already have the paint (and the width) are skipped: when +/// every face or edge the call reaches has it, nothing changes, and when some do, only the others +/// are painted, in one undo step. `count` is every face or edge reached. +#[test] +fn live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"})).unwrap(); + let edges = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3})).unwrap(); + redo_waiting_and_saved(&mut s, g); + let mut before = snapshot(&s); + let same = json!({"group": g.0, "point": [75, 50], "scope": "samePaint", "color": "#00ff00"}); + assert_eq!(s.execute("livePaint.fill", &same).unwrap()["count"], 3); + assert_unchanged(&mut s, &mut before, "a samePaint fill where every face has the paint"); + let again = json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3}); + assert_eq!(s.execute("livePaint.strokeEdge", &again).unwrap()["count"], edges["count"]); + assert_unchanged(&mut s, &mut before, "a samePaint stroke where every edge has the paint and the width"); + // One face of the three without the paint: only it is filled, in one undo step. + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let red = Paint::solid(Color::from_hex("#ff0000").unwrap()); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#ff0000"})).unwrap(); + let depth = history_state(&s).0; + let like = serde_json::to_value(&green).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "scope": "samePaint", "match": like, "color": "#00ff00"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!(history_state(&s).0, depth + 1); + let fill_at = |s: &Session, x: f64| vectorcraft_tools::builder::face_at(&node(s, g), Point::new(x, 50.0)).unwrap().appearance.fill_paint(); + for x in [25.0, 75.0, 125.0] { + assert_eq!(fill_at(&s, x), green, "{x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_at(&s, 25.0), red); + assert_eq!((fill_at(&s, 75.0), fill_at(&s, 125.0)), (green.clone(), green)); +} + #[test] fn live_paint_gap_options_same_values_are_no_op() { let mut s = session(); diff --git a/crates/mcp/src/tests/livepaint.rs b/crates/mcp/src/tests/livepaint.rs index f3952a90f6..0165823e6f 100644 --- a/crates/mcp/src/tests/livepaint.rs +++ b/crates/mcp/src/tests/livepaint.rs @@ -47,3 +47,27 @@ fn a_triple_click_fills_every_face_of_the_same_fill() { ok(&mut h, "pointer_gesture", json!({"events": [ev("tripleclick")]})); assert_eq!(fills(&mut h, &g), [after_two[0].clone(), after_two[0].clone()], "both faces were red"); } + +/// With Space held (`mods.space`), the Live Paint Bucket's clicks, double-click and triple-click +/// paint nothing. +#[test] +fn clicks_with_space_held_paint_nothing() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + let a = run(&mut h, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let b = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let g = run(&mut h, "livePaint.make", json!({"ids": [a, b]}))["id"].clone(); + for x in [25, 125] { + run(&mut h, "livePaint.fill", json!({"group": g, "point": [x, 25], "color": "#ff0000"})); + } + run(&mut h, "select.none", json!({})); + run(&mut h, "paint.setFill", json!({"color": "#00ff00"})); + let red = fills(&mut h, &g); + let ev = |kind: &str| json!({"kind": kind, "x": 25, "y": 25}); + let clicks = [ev("down"), ev("up"), ev("down"), ev("up"), ev("doubleclick"), ev("down"), ev("up"), ev("tripleclick")]; + ok(&mut h, "pointer_gesture", json!({"tool": "livePaintBucket", "mods": {"space": true}, "events": clicks})); + assert_eq!(fills(&mut h, &g), red); + // Without Space, the same clicks paint. + ok(&mut h, "pointer_gesture", json!({"events": [ev("down"), ev("up")]})); + assert_ne!(fills(&mut h, &g)[0], red[0]); +} diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index ff59b22333..56e8e654e3 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -178,14 +178,16 @@ enum LpPaint { /// A triple-click on a face fills every face of the group with the face's fill, and on an edge it /// paints every edge of the group with the edge's stroke (`scope: "samePaint"`). Each click of a /// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared -/// is the one the face or edge had before the first of its clicks. Shift and the options choose a -/// face or an edge as for a click; with Alt held a double-click or triple-click paints nothing. With -/// Space held for the view tools, a click, double-click or triple-click paints nothing. +/// is the one the face or edge had before the first of its clicks that painted it. A first click +/// that made the Live Paint group counts when the face it filled is the clicked face. Shift and the +/// options choose a face or an edge as for a click; with Alt held a double-click or triple-click +/// paints nothing. With Space held for the view tools, a click, double-click or triple-click paints +/// nothing and no face or edge is highlighted. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, - /// Where the pointer last moved, and whether Shift and Alt were held. - at: Option<(Point, bool, bool)>, + /// Where the pointer last moved, and the modifiers held there. + at: Option<(Point, Mods)>, /// The fill command and polyline previewed from the press's snapshot. fill_drag: Option<(Value, Vec>)>, /// The last [`LP_PRESSES`] presses, oldest first: the face or edge each one painted, with its @@ -304,17 +306,19 @@ impl LivePaintBucketTool { self.note_press(Some(LpPress { part: Some(n.id), at, before })); } - /// The paint `part` had before the first of the last presses of a click sequence of `clicks` - /// presses that all painted `part` (the sequence's first press may have made the group `part` - /// is in, when the face it filled there is `part`); `now` when the last press did not paint it. + /// The paint `part` had before the first press of a click sequence of `clicks` presses that + /// painted `part` (the sequence's first press counts when it made the group `part` is in and + /// the face it filled there is `part`); `now` when the last press did not paint it. fn before(&self, cx: &ToolContext, part: NodeId, clicks: usize, now: Paint) -> Paint { let Some(run) = self.presses.len().checked_sub(clicks).and_then(|from| self.presses.get(from..)) else { return now }; let made_part = |q: &LpPress| live_paint_at(cx.doc, q.at).and_then(|g| visible_face_at(cx.doc.node(g)?, q.at)).is_some_and(|f| f.id == part); let on_part = |(i, q): (usize, &Option)| q.as_ref().is_some_and(|q| q.part == Some(part) || (i == 0 && q.part.is_none() && made_part(q))); - let from = run.len().saturating_sub(run.iter().enumerate().rev().take_while(|&iq| on_part(iq)).count()); - match run.get(from) { - Some(Some(first)) => first.before.clone(), + if !run.last().is_some_and(|q| on_part((run.len() - 1, q))) { + return now; + } + match run.iter().enumerate().find(|&iq| on_part(iq)) { + Some((_, Some(first))) => first.before.clone(), _ => now, } } @@ -322,7 +326,7 @@ impl LivePaintBucketTool { /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. fn multi_click(&self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { - // With Space held the press pans, zooms or rotates the view and does not reach the tool. + // With Alt held, or with Space held for the view tools, a double-click or triple-click paints nothing. if mods.alt || mods.space { return vec![]; } @@ -350,14 +354,14 @@ impl LivePaintBucketTool { vec![Action::Exec("livePaint.fill".into(), v)] } - /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with - /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's - /// map is still made, for the cursor ([`Self::paints_at`]). - fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { - if alt { + /// The highlight at `p` for a pointer with the modifiers `mods`: none with Alt, with Space held + /// for the view tools or with Highlight off, else the face or edge a click there paints. With + /// Highlight off the selection's map is still made, for the cursor ([`Self::paints_at`]). + fn hover_at(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> LpHover { + if mods.alt || mods.space { return LpHover::None; } - let target = self.target_at(cx, p, shift); + let target = self.target_at(cx, p, mods.shift); if self.highlight { target } else { LpHover::None } } @@ -432,13 +436,13 @@ impl Tool for LivePaintBucketTool { } match ev.kind { PointerKind::Move => { - self.at = Some((p, ev.mods.shift, ev.mods.alt)); - self.hover = self.hover_at(cx, p, ev.mods.shift, ev.mods.alt); + self.at = Some((p, ev.mods)); + self.hover = self.hover_at(cx, p, ev.mods); vec![] } PointerKind::Down => { self.hover = LpHover::None; - // With Space held the press pans, zooms or rotates the view. + // With Space held for the view tools, a press paints nothing. if ev.mods.space { self.note_press(None); return vec![]; @@ -573,8 +577,8 @@ impl Tool for LivePaintBucketTool { // of a Live Paint group the hover takes no map. loop { self.cache.settle(wait); - if let Some((p, shift, alt)) = self.at { - self.hover = self.hover_at(cx, p, shift, alt); + if let Some((p, mods)) = self.at { + self.hover = self.hover_at(cx, p, mods); } if !wait || !self.cache.waiting() { break; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index a0811c5071..42efee075c 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1254,6 +1254,51 @@ fn bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_fa assert_eq!(v["match"], own); } +/// A triple-click whose clicks land on one face, another face, then the first face again matches +/// the fill the first face had before the first click on it, not the fill that click gave it. +#[test] +fn bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill() { + use vectorcraft_color::{Color, Paint}; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let (d, gid, f2, _) = live_group_doc(); + let mut painted = d.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(red.clone()); + let s = Selection::default(); + let p = paint(); + let (cx, later) = (cx(&d, &s, &p), cx(&painted, &s, &p)); + let mut t = LivePaintBucketTool::default(); + // The first click paints the face at (75, 25) red; the second fills the face at (25, 25). + for (ctx, x) in [(&cx, 75.0), (&later, 25.0), (&later, 75.0)] { + t.pointer(ctx, &ev(PointerKind::Down, x, 25.0)); + t.pointer(ctx, &ev(PointerKind::Up, x, 25.0)); + } + let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let own = serde_json::to_value(d.node(f2).unwrap().appearance.fill_paint()).unwrap(); + assert_ne!(own, serde_json::to_value(&red).unwrap()); + assert_eq!( + acts, + vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [75.0, 25.0], "scope": "samePaint", "match": own }))] + ); +} + +/// With Space held for the view tools, the pointer outlines no face or edge, as a press there +/// paints nothing. +#[test] +fn bucket_highlights_nothing_with_space_held() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let space = Mods { space: true, ..Default::default() }; + for (x, m) in [(75.0, space), (51.0, Mods { shift: true, ..space })] { + t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); + assert!(t.overlays(&cx).is_empty(), "at {x} with {m:?}"); + } + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert!(!t.overlays(&cx).is_empty()); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); diff --git a/docs/mcp.md b/docs/mcp.md index 9887f22044..4ece60e032 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3531,14 +3531,15 @@ already walks Image Trace. Short agent entry points: the pointer is not outlined. A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with `scope: "samePaint"`; Alt held runs nothing. With Space held (`mods.space`, the view tools), a press, a - double-click or a triple-click runs nothing. On a face, `connected` fills the face and every face reached from it + double-click or a triple-click runs nothing, and a move outlines no face or edge. On a face, `connected` fills the face and every face reached from it across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap that Gap Detection closes and the group's outside stop it. `samePaint` fills every face of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the edges joined to it end to end whose stroke paint is `match`, and `samePaint` paints every edge of the group with that stroke paint; the stroke's weight is not compared. Each click of a double-click or triple-click runs as it arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's - two clicks or the triple-click's three that painted it, so the clicks before the last do not change what it matches. Hidden faces and edges are not painted. These rules + two clicks or the triple-click's three that painted it, so the clicks before the last do not change what it matches; + a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. These rules for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4a7bb708b..93639260db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1437,7 +1437,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. - Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face}`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face}`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From ce3445d10685348bffd5b285dba83767cbea1d31 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:38:18 -0800 Subject: [PATCH 11/45] Live Paint: a Cmd double-click or triple-click with the Bucket paints nothing With Cmd (Ctrl) held, the Bucket's presses go to the selection tool Cmd lends, but the double-click and triple-click that follow reached the Bucket and painted. They now paint nothing, as with Alt. --- crates/engine/src/tests/build.rs | 22 ++++++++++++++++++++++ crates/tools/src/builder/live_paint.rs | 9 +++++---- crates/tools/src/builder/tests.rs | 1 + docs/mcp.md | 3 ++- docs/target-app-parity.md | 2 +- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 92d7c8caf0..49f6286e45 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3046,6 +3046,28 @@ fn live_paint_bucket_double_click_floods_across_unstroked_edges() { assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green, white]); } +/// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the +/// double-click and triple-click that follow them paint nothing. +#[test] +fn live_paint_bucket_cmd_double_and_triple_click_paint_nothing() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0]); + let faces = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0)]); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 151.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let before: Vec = faces.iter().map(|f| fill_of(&s, *f)).collect(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let cmd = Mods { cmd: true, ..Default::default() }; + let (down, up) = (PointerKind::Down, PointerKind::Up); + for kind in [down, up, down, up, PointerKind::DoubleClick, down, up, PointerKind::TripleClick] { + s.pointer(&PointerEvent::new(kind, 75.0, 25.0).with_mods(cmd), v).unwrap(); + } + assert_eq!(faces.iter().map(|f| fill_of(&s, *f)).collect::>(), before); + assert_eq!(s.tool_id(), "livePaintBucket"); +} + /// The flood crosses curved edges, and reaches a face 0.012 pt wide. #[test] fn live_paint_bucket_double_click_floods_curved_and_narrow_faces() { diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index fab4663d9e..2ea049a9fb 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -180,8 +180,8 @@ enum LpPaint { /// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared /// is the one the face or edge had before the first of its clicks that painted it. A first click /// that made the Live Paint group counts when the face it filled is the clicked face. Shift and the -/// options choose a face or an edge as for a click; with Alt held a double-click or triple-click -/// paints nothing. With Space held for the view tools, a click, double-click or triple-click paints +/// options choose a face or an edge as for a click; with Alt or Cmd/Ctrl held a double-click or +/// triple-click paints nothing. With Space held for the view tools, a click, double-click or triple-click paints /// nothing and no face or edge is highlighted. /// /// The Left and Right arrow keys without Shift, Alt or Cmd/Ctrl set the active paint (the fill, @@ -352,8 +352,9 @@ impl LivePaintBucketTool { /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. fn multi_click(&self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { - // With Alt held, or with Space held for the view tools, a double-click or triple-click paints nothing. - if mods.alt || mods.space { + // With Alt held, with Space held for the view tools, or with Cmd/Ctrl held (its presses went + // to the selection tool Cmd lends), a double-click or triple-click paints nothing. + if mods.alt || mods.space || mods.cmd { return vec![]; } let point = json!([p.x, p.y]); diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index eb6f806246..c44863531d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1150,6 +1150,7 @@ fn bucket_double_and_triple_click_widen_what_a_click_paints() { let space = Mods { space: true, ..Default::default() }; for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(alt)).is_empty(), "{k:?}"); + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(Mods { cmd: true, ..Default::default() })).is_empty(), "{k:?} with Cmd"); assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(space)).is_empty(), "{k:?} with Space"); assert!(t.pointer(&cx, &ev(k, 51.0, 25.0).with_mods(Mods { shift: true, ..space })).is_empty(), "{k:?} with Shift and Space"); } diff --git a/docs/mcp.md b/docs/mcp.md index b34d1dc880..352602d978 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3536,7 +3536,8 @@ already walks Image Trace. Short agent entry points: Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under the pointer is not outlined. A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with - `scope: "samePaint"`; Alt held runs nothing. With Space held (`mods.space`, the view tools), a press, a + `scope: "samePaint"`; Alt held runs nothing, and so does Cmd/Ctrl (`mods.cmd`), whose presses go to the selection + tool Cmd lends. With Space held (`mods.space`, the view tools), a press, a double-click or a triple-click runs nothing, and a move outlines no face or edge. On a face, `connected` fills the face and every face reached from it across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap that Gap Detection closes and the group's outside stop it. `samePaint` fills every face diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 824d6c594d..ce24a37432 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1445,7 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. - Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing and no face or edge is outlined. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face, bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill, bucket_highlights_nothing_with_space_held}`, `tests::build::live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint`, `tests::livepaint::{a_triple_click_fills_every_face_of_the_same_fill, clicks_with_space_held_paint_nothing}` (MCP). + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing and no face or edge is outlined, and with Cmd/Ctrl held a double-click or triple-click paints nothing. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face, bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill, bucket_highlights_nothing_with_space_held}`, `tests::build::{live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint, live_paint_bucket_cmd_double_and_triple_click_paint_nothing}`, `tests::livepaint::{a_triple_click_fills_every_face_of_the_same_fill, clicks_with_space_held_paint_nothing}` (MCP). Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. From 99b49f8cd419f32b8d08d5569b7fb12ca498d158 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:23:59 -0800 Subject: [PATCH 12/45] i18n: Ukrainian Color Library names colors, not flowers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ukrainian catalog translated Color Library and Color Library… as "Бібліотека квітів" (library of flowers). Both rows now read "Бібліотека кольорів", the genitive plural of колір used elsewhere in the catalog. A test checks both labels through the catalog lookup. No other Ukrainian row uses квіт- for color. The Russian, Bulgarian, Bosnian, Czech and Polish labels already name colors. --- crates/ui-egui/src/i18n/tests.rs | 7 +++++++ crates/ui-egui/src/i18n/uk.tsv | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..c3774c8ae0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1834,6 +1834,13 @@ fn uk() -> Lang { Lang::from_code("uk").expect("uk registered") } +/// The Ukrainian Color Library labels use the word for colors (кольорів), not flowers (квітів). +#[test] +fn ukrainian_color_library_labels_name_colors() { + assert_eq!(uk().catalog().plain("Color Library"), Some("Бібліотека кольорів")); + assert_eq!(uk().catalog().plain("Color Library…"), Some("Бібліотека кольорів…")); +} + /// Every Ukrainian plural has all three forms; exercise different endings through real lookups. #[test] fn ukrainian_plurals_have_three_forms() { diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..731caff5af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -540,8 +540,8 @@ font-technology Variable Варіативні Color Guide Options Параметри колірного гіда Color Guide Options… Параметри колірного гіда… Color images Кольорові зображення - Color Library Бібліотека квітів - Color Library… Бібліотека квітів… + Color Library Бібліотека кольорів + Color Library… Бібліотека кольорів… Color Management Керування кольором Color Mixer (Shift-click the chip) Змішувач кольорів (Shift+клацання на зразку) Color Mode Колірний режим From 21da73402fd8bc28264a0d13b435faf85c016934 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:56:22 -0800 Subject: [PATCH 13/45] Live Paint: Make keeps every fill and stroke row that draws on the source Object > Live Paint > Make gave each new face and edge one fresh row built from only the source's topmost fill or stroke paint and the top stroke's weight. A lower row that draws was lost, a hidden top row became visible, and the rows' opacity, blending mode, effects and stroke settings were dropped. - A face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, in order with their settings. A source in front whose fill does not draw no longer hides the fill behind it. - An edge takes every stroke row of its source that draws (also with a weight above 0). Faces and edges take the source's opacity and blending mode; gradients keep their fit on the source, and pen pressure stays with the source. - Where a path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. - Rows that draw nothing stay with the stored sources, which Release returns unchanged. Merge and Gap Options build their new parts the same way. Docs: livePaint.make params, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/buildcmds.rs | 274 +++++++++++++++++++++++++++-- crates/engine/src/tests/build.rs | 234 +++++++++++++++++++++++- docs/mcp.md | 15 +- docs/target-app-parity.md | 1 + 4 files changed, 510 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3fc5953619..a1c06e6490 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -414,22 +414,256 @@ fn source_nodes(n: &Node) -> Vec> { if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] } } +/// Does appearance row `i` draw: it is shown, has paint and an opacity above 0, and a stroke also +/// has a weight above 0? +fn row_draws(i: &AppearanceItem) -> bool { + match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && f.opacity > 0.0, + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.opacity > 0.0 && s.width > 0.0, + } +} + +/// What a source path gives the faces and edges made from it. +struct SourceStyle { + /// The fill rows that draw, in paint order (none when the path's opacity is 0). + fills: Vec, + /// The stroke rows that draw, in paint order (none when the path's opacity is 0). + strokes: Vec, + /// The top stroke's weight, for an edge given no stroke that draws. + width: f64, + opacity: f32, + blend: BlendMode, +} + +impl SourceStyle { + /// The style of source path `n`: its rows keep their order and settings, gradients fitted to + /// its bounds keep that fit, and the strokes' pen pressure, tilt, bearing and rotation stay + /// with the path. + fn of(n: &Node) -> Self { + let mut a = (*n.appearance).clone().without_pressure(); + if a.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + a.pin_gradients(bounds); + } + let width = a.stroke().map_or(1.0, |s| s.width); + let (mut fills, mut strokes) = (vec![], vec![]); + if n.opacity > 0.0 { + for i in a.items.into_iter().filter(row_draws) { + if i.is_fill() { fills.push(i) } else { strokes.push(i) } + } + } + Self { fills, strokes, width, opacity: n.opacity, blend: n.blend } + } + + /// Give part `n` the rows `rows` of this source with its opacity and blending mode; with no + /// rows, `n` keeps the unpainted row it has. + fn paint(&self, n: &mut Node, rows: &[AppearanceItem]) { + if rows.is_empty() { + return; + } + n.appearance = Appearance { items: rows.to_vec(), ..Default::default() }.into(); + n.opacity = self.opacity; + n.blend = self.blend; + } +} + +/// How far, in points, a place on an edge may be from its source path and still be on it. +const ON_SOURCE: f64 = 0.05; + +/// Does stroke row `i` have settings that depend on where along its path a piece of it lies: +/// dashes, arrowheads, a width profile or a brush? +fn depends_on_place(i: &AppearanceItem) -> bool { + matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some()) +} + +/// One subpath's segments with the length along it where each starts, then its whole length, and +/// a grid of square cells listing the segments whose bounds, grown by [`ON_SOURCE`], reach into +/// each cell, so finding the segments near a point does not look at every segment. +struct Track { + segs: Vec, + at: Vec, + closed: bool, + cell: f64, + grid: HashMap<(i64, i64), Vec>, + /// Segments that reach into more than [`Track::MAX_CELLS`] cells, or that come after the grid + /// holds [`Track::CELLS_PER_SEGMENT`] entries per segment, looked at for every point. + wide: Vec, +} + +impl Track { + const ACCURACY: f64 = 1e-4; + const MAX_CELLS: i64 = 4096; + const CELLS_PER_SEGMENT: usize = 16; + + fn new(sp: &SubPath) -> Self { + use kurbo::{ParamCurveArclen, Shape as _}; + let mut bp = kurbo::BezPath::new(); + sp.to_bezpath_into(&mut bp); + let segs: Vec = bp.segments().collect(); + let mut at = Vec::with_capacity(segs.len() + 1); + let mut len = 0.0; + for s in &segs { + at.push(len); + len += s.arclen(Self::ACCURACY); + } + at.push(len); + let boxes: Vec = segs.iter().map(|s| s.bounding_box().inflate(ON_SOURCE, ON_SOURCE)).collect(); + // Cells about as wide as an average segment's bounds. + let mean = boxes.iter().map(|b| b.width().max(b.height())).sum::() / boxes.len().max(1) as f64; + let cell = if mean.is_finite() { mean.max(4.0 * ON_SOURCE) } else { 4.0 * ON_SOURCE }; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut wide = vec![]; + // The grid holds at most this many entries in all. + let mut budget = boxes.len().saturating_mul(Self::CELLS_PER_SEGMENT); + for (k, b) in boxes.iter().enumerate() { + let (x0, y0) = Self::cell_of(Point::new(b.x0, b.y0), cell); + let (x1, y1) = Self::cell_of(Point::new(b.x1, b.y1), cell); + let cells = x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1)); + let Some(cells) = usize::try_from(cells).ok().filter(|&c| cells <= Self::MAX_CELLS && c <= budget) else { + wide.push(k); + continue; + }; + budget -= cells; + for x in x0..=x1 { + for y in y0..=y1 { + grid.entry((x, y)).or_default().push(k); + } + } + } + Self { segs, at, closed: sp.closed, cell, grid, wide } + } + + fn cell_of(p: Point, cell: f64) -> (i64, i64) { + ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The lengths along the subpath to each of its segments' places nearest `p` that are within + /// [`ON_SOURCE`] of it (more than one where the subpath passes `p` again). + fn places(&self, p: Point) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = self.grid.get(&Self::cell_of(p, self.cell)).map(Vec::as_slice).unwrap_or_default(); + let mut out = vec![]; + for &k in near.iter().chain(&self.wide) { + let Some(s) = self.segs.get(k) else { continue }; + let n = s.nearest(p, 1e-9); + if n.distance_sq <= ON_SOURCE * ON_SOURCE { + out.push(self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..n.t).arclen(Self::ACCURACY)); + } + } + out + } + + /// The signed length from `a` to `b` along the subpath (the shorter way round a closed one). + fn step(&self, a: f64, b: f64) -> f64 { + let (d, len) = (b - a, self.len()); + if self.closed && len > 0.0 { d - len * (d / len).round() } else { d } + } + + /// Of `places`, the one nearest along the subpath to `target`. + fn nearest(&self, places: &[f64], target: f64) -> Option { + places.iter().copied().min_by(|x, y| self.step(*x, target).abs().total_cmp(&self.step(*y, target).abs())) + } +} + +/// Where edge `edge` runs along its source path, whose subpaths' tracks are `tracks`: the length +/// along the source subpath to the edge's start, whether the edge starts at that subpath's start and +/// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the +/// edge's first segment places it, and its ends are taken where they lie that far along the source, +/// so a path that passes a point twice is told apart. An edge that runs against the direction of its +/// source is reversed first. `None` when the edge is not a single piece found on the source. +fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { + use kurbo::{ParamCurve, ParamCurveArclen}; + let [sp] = edge.subpaths.as_slice() else { return None }; + if sp.segment_count() == 0 { + return None; + } + let (a, z) = (sp.anchors.first()?.p, if sp.closed { sp.anchors.first()?.p } else { sp.anchors.last()?.p }); + let first = sp.segment(0); + let mid = first.eval(0.5); + let half = first.subsegment(0.0..0.5).arclen(Track::ACCURACY); + let length = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let edge_closed = sp.closed; + // (how far off the fit is, track, start, end, runs forward) for the subpath that fits best. + let mut found: Option<(f64, &Track, f64, f64, bool)> = None; + for track in tracks { + // The start lies half the first segment before its middle along the source, or after it + // when the edge runs the other way: take the place and way that fit best. + let starts_at = track.places(a); + let mut best: Option<(f64, f64, bool)> = None; + for pm in track.places(mid) { + for forward in [true, false] { + let Some(pa) = track.nearest(&starts_at, if forward { pm - half } else { pm + half }) else { continue }; + let miss = (if forward { track.step(pa, pm) } else { track.step(pm, pa) } - half).abs(); + if best.is_none_or(|(m, _, _)| miss < m) { + best = Some((miss, pa, forward)); + } + } + } + let Some((miss, pa, forward)) = best else { continue }; + let target = if forward { pa + length } else { pa - length }; + let Some(pz) = track.nearest(&track.places(z), target) else { continue }; + let miss = miss + track.step(pz, target).abs(); + if found.is_none_or(|(m, ..)| miss < m) { + found = Some((miss, track, pa, pz, forward)); + } + } + let (_, track, pa, pz, forward) = found?; + let (from, to) = if forward { (pa, pz) } else { (pz, pa) }; + if !forward { + edge.reverse(); + } + let open = !track.closed; + let starts = open && from <= ON_SOURCE; + let ends = open && to >= track.len() - ON_SOURCE; + let whole = if open { starts && ends } else { edge_closed }; + Some((from, starts, ends, whole)) +} + +/// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says +/// (`None`: not known): its dashes go on from where the edge starts along the source, its +/// arrowheads stay only at the source's own ends, and its width profile and brush, which spread +/// over the whole path, stay only on an edge that runs the whole path. +fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) { + let AppearanceItem::Stroke(s) = row else { return }; + let (from, starts, ends, whole) = run.unwrap_or((0.0, false, false, false)); + if !whole { + if let Some(d) = &mut s.dash + && run.is_some() + { + d.offset += from; + d.align_corners = false; + } + s.profile = None; + s.brush = None; + } + if !starts { + s.start_arrow = None; + } + if !ends { + s.end_arrow = None; + } +} + /// The planar map's inputs from `sources` (paths and compound paths, back → front), with the -/// fill, stroke and stroke width of each. -fn source_shapes(sources: &[Arc]) -> (Vec, Vec<(Paint, Paint, f64)>) { +/// style of each. +fn source_shapes(sources: &[Arc]) -> (Vec, Vec) { let mut lv = vec![]; for n in sources { b::leaf_shapes(n, &mut lv); } let mut shapes = vec![]; - let mut styles: Vec<(Paint, Paint, f64)> = vec![]; + let mut styles: Vec = vec![]; for l in lv { if let Some((path, rule)) = b::node_outline(l) && !path.is_empty() { shapes.push(po::Shape::new(path, rule, styles.len() as u64)); - let a = &l.appearance; - styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0))); + styles.push(SourceStyle::of(l)); } } (shapes, styles) @@ -462,17 +696,35 @@ fn build_children(d: &mut Document, sources: Vec>, max_gap: Option> = HashMap::new(); for e in edges { - let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue }; + let key = e.key as usize; + let Some(s) = styles.get(key) else { continue }; + let mut path = e.path; + let mut rows = s.strokes.clone(); + if rows.iter().any(depends_on_place) { + let source = tracks + .entry(key) + .or_insert_with(|| shapes.get(key).map(|src| src.path.subpaths.iter().map(Track::new).collect()).unwrap_or_default()); + let run = edge_run(source, &mut path); + for r in &mut rows { + fit_to_edge(r, run); + } + } let id = d.alloc_id(); - let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w)); + let mut n = Node::path(id, path, stroke_only(Paint::None, s.width)); n.name = Some(EDGE_NAME.into()); + s.paint(&mut n, &rows); children.push(Arc::new(n)); } Ok((children, nfaces, nedges)) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 1c3ad5d8b4..23674e63d0 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::appearance::{Effect, FillLayer, StrokeLayer}; use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; @@ -2597,6 +2598,237 @@ fn live_paint_make_and_expand_keep_abutting_compound_colors() { check_colors(&node(&s, group)); } +/// Gives `id` the appearance rows `items` (paint order) and returns them. +fn set_rows(s: &mut Session, id: NodeId, items: Vec) -> Vec { + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance = vectorcraft_doc::Appearance { items: items.clone(), ..Default::default() }.into(); + Ok(()) + }) + .unwrap(); + items +} + +/// A fill row of `p`, changed by `f`. +fn fill_row(p: Paint, f: impl FnOnce(&mut FillLayer)) -> AppearanceItem { + let mut l = FillLayer::new(p); + f(&mut l); + AppearanceItem::Fill(l) +} + +/// A stroke row of `p` `width` wide, changed by `f`. +fn stroke_row(p: Paint, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> AppearanceItem { + let mut l = StrokeLayer::new(p, width); + f(&mut l); + AppearanceItem::Stroke(l) +} + +/// The faces and edges of Live Paint group `g` whose bounds lie within x `x0`..`x1`. +fn parts_within(s: &Session, g: NodeId, x0: f64, x1: f64) -> (Vec, Vec) { + let n = node(s, g); + let within = |c: &&Arc| c.geometric_bounds().is_some_and(|b| b.x0 >= x0 - 1e-6 && b.x1 <= x1 + 1e-6); + let faces = vectorcraft_tools::builder::faces(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + let edges = vectorcraft_tools::builder::edges(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + (faces, edges) +} + +/// Make gives each edge every stroke row of its source that draws, a lower one under an +/// unpainted or hidden top row included, with the rows' settings and the source's opacity and +/// blending mode; rows that draw nothing stay only with the stored source, which Release returns. +#[test] +fn live_paint_make_keeps_lower_drawn_stroke_rows() { + let mut s = session(); + let (a, b) = two_at(&mut s, 200.0); + let black = Paint::solid(Color::BLACK); + let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let thick = stroke_row(black.clone(), 3.0, |l| { + l.opacity = 0.5; + l.blend = BlendMode::Multiply; + l.cap = LineCap::Round; + }); + // A: the top stroke has no paint. + let a_rows = + set_rows(&mut s, a, vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {}), thick.clone(), stroke_row(Paint::None, 1.0, |_| {})]); + // B: the top stroke is hidden; a zero-weight and a transparent stroke draw nothing either. + set_rows( + &mut s, + b, + vec![ + stroke_row(green.clone(), 2.0, |_| {}), + stroke_row(black.clone(), 0.0, |_| {}), + stroke_row(black, 2.0, |l| l.opacity = 0.0), + stroke_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 5.0, |l| l.visible = false), + ], + ); + s.edit("t", |d, _| { + let n = d.node_mut(a).unwrap(); + n.opacity = 0.75; + n.blend = BlendMode::Screen; + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let (_, a_edges) = parts_within(&s, g, 0.0, 100.0); + let (_, b_edges) = parts_within(&s, g, 200.0, 300.0); + assert!(!a_edges.is_empty() && !b_edges.is_empty()); + for e in &a_edges { + assert_eq!(e.appearance.items, vec![thick.clone()], "A's edge takes the stroke that draws"); + assert_eq!((e.opacity, e.blend), (0.75, BlendMode::Screen)); + } + for e in &b_edges { + assert_eq!(e.appearance.items, vec![stroke_row(green.clone(), 2.0, |_| {})], "B's edge takes only its green stroke"); + assert_eq!((e.opacity, e.blend), (1.0, BlendMode::Normal)); + } + // Release returns the sources with every row they had. + let released = ids(&s.execute("livePaint.release", &json!({})).unwrap()); + assert!(released.contains(&a)); + assert_eq!(node(&s, a).appearance.items, a_rows); +} + +/// When Make cuts a dashed stroke with arrowheads, a width profile and a brush into edges, each +/// edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own +/// ends, and the profile and brush, which spread over the whole path, are left off the pieces. +#[test] +fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { + use vectorcraft_doc::{Arrowhead, Dash}; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (-20.0, 50.0), (120.0, 50.0)); + let dash = Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: true }; + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(dash.clone()); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::Circle); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, l, vec![row]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let mut pieces: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&gn) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone(); + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x, st)) + }) + .collect(); + pieces.sort_by(|p, q| p.0.total_cmp(&q.0)); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| ((p.0 * 1e3).round() / 1e3, (p.1 * 1e3).round() / 1e3)).collect(); + assert_eq!(ends, vec![(-20.0, 0.0), (0.0, 100.0), (100.0, 120.0)], "each piece runs the way the line does"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 22.0, 122.0], "the dashes go on along the line"); + assert!(pieces.iter().all(|p| !p.2.dash.as_ref().unwrap().align_corners && p.2.brush.is_none())); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, None), (None, Some(Arrowhead::Circle))]); + // A path that crosses itself: the pieces that start at the crossing take their place on the + // path from where they run, not from the first time the path passes that point. + let mut s3 = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s3.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()); + set_rows(&mut s3, z, rows.clone()); + s3.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g3 = NodeId(s3.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let mut cut: Vec<(f64, Option, Option)> = vectorcraft_tools::builder::edges(&node(&s3, g3)) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke()?; + Some(((st.dash.as_ref()?.offset * 1e3).round() / 1e3, st.start_arrow.clone(), st.end_arrow.clone())) + }) + .collect(); + cut.sort_by(|p, q| p.0.total_cmp(&q.0)); + let d = 2.0 + 50.0 * 2f64.sqrt(); + let r = |v: f64| (v * 1e3).round() / 1e3; + assert_eq!( + cut, + vec![(2.0, Some(Arrowhead::Triangle), None), (r(d), None, None), (r(d + 100.0 + 100.0 * 2f64.sqrt()), None, Some(Arrowhead::Circle))] + ); + // An uncut path keeps all of it. + let mut s2 = session(); + let l2 = line(&mut s2, (0.0, 0.0), (50.0, 0.0)); + set_rows(&mut s2, l2, rows.clone()); + s2.execute("select.set", &json!({"ids": [l2.0]})).unwrap(); + let g2 = NodeId(s2.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let edges: Vec<_> = vectorcraft_tools::builder::edges(&node(&s2, g2)).iter().map(|e| e.appearance.items.clone()).collect(); + assert_eq!(edges, vec![rows]); +} + +/// Make gives each face every fill row of its source that draws, in order and with their +/// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on +/// the source; Expand keeps the rows, and Undo and Redo bring the group back with them. +#[test] +fn live_paint_make_keeps_all_drawn_fill_rows_and_attributes() { + let mut s = session(); + let (a, b) = two(&mut s); + let lower = fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |l| { + l.opacity = 0.4; + l.blend = BlendMode::Multiply; + l.overprint = true; + l.effects.push(Effect { id: "distort.roughen".into(), params: Value::Null, visible: true }); + }); + // A: two fills that draw, with a hidden one between them and an unpainted one above them. + set_rows( + &mut s, + a, + vec![ + lower.clone(), + fill_row(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), |l| l.visible = false), + fill_row(Paint::None, |_| {}), + stroke_row(Paint::None, 1.0, |_| {}), + ], + ); + // The top fill (and the stroke) become a gradient fitted to A's bounds. + let (want_fill, _) = paint_with_gradients(&mut s, a); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let want = vec![lower, fill_row(want_fill, |_| {})]; + let rows_within = |s: &Session, x0: f64, x1: f64| parts_within(s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()], "A's face takes both fills that draw, the gradient at its fit on A"); + let blue = vec![fill_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), |_| {})]; + assert_eq!(rows_within(&s, 50.0, 100.0), vec![blue], "B is in front"); + s.execute("livePaint.expand", &json!({})).unwrap(); + let expanded = node(&s, g); + assert!(!vectorcraft_tools::builder::is_live_paint(&expanded)); + let kept = + expanded.children().unwrap().iter().filter(|c| c.geometric_bounds().is_some_and(|b| b.x1 <= 50.0 + 1e-6)).any(|c| c.appearance.items == want); + assert!(kept, "Expand keeps both fills"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want]); +} + +/// A face takes the fill rows of the front-most covering source whose fill draws: a source in +/// front whose fill is hidden or transparent, or whose opacity is 0, does not hide the paint of the +/// source behind it, and a face that no such source covers has no paint. +#[test] +fn live_paint_make_uses_frontmost_source_with_drawn_fill() { + for front in ["hidden", "clear", "transparent object"] { + let mut s = session(); + let (a, b) = two(&mut s); + s.edit("t", |d, _| { + let n = d.node_mut(b).unwrap(); + let mut app = (*n.appearance).clone(); + match front { + "hidden" => app.fill_mut().unwrap().visible = false, + "clear" => app.fill_mut().unwrap().opacity = 0.0, + _ => n.opacity = 0.0, + } + n.appearance = app.into(); + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let rows_within = |x0: f64, x1: f64| parts_within(&s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + let red = vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {})]; + assert_eq!(rows_within(50.0, 100.0), vec![red], "{front}: the overlap shows A"); + assert_eq!(rows_within(100.0, 150.0), vec![vec![fill_row(Paint::None, |_| {})]], "{front}: B alone paints nothing"); + assert_eq!(parts_within(&s, g, 100.0, 150.0).0[0].opacity, 1.0, "{front}"); + } +} + #[test] fn live_paint_fill_assigns_the_face_under_the_point() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 1ad05ba3df..35a1b4e74f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3525,8 +3525,19 @@ already walks Image Trace. Short agent entry points: `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, - color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most - covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. + color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill rows that draw of + its front-most covering source that has one, including compound paths with shared curved boundaries, and each edge + the stroke rows that draw of its source. A row draws when it is shown and has paint and an opacity above 0; a + stroke also needs a weight above 0, and a source whose opacity is 0 has no rows that draw. The rows keep their order + and settings (opacity, blending mode, effects, overprint, and a stroke's weight, caps, joins, dashes and alignment), + gradients keep their fit on the source, and the face or edge takes the source's opacity and blending mode. Where a + path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along + the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width + profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on + each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. + A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. + Expand keeps those face fills. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c74302900..eb1623d225 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1457,6 +1457,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. + Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From 6e2123709e39a075424973c8b91202f45464d2e3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:39:39 -0800 Subject: [PATCH 14/45] Blob Brush: a long drag's settled outline is reused from frame to frame The outline a Blob Brush or Eraser drag shows was copied, flattened and tessellated again every frame, settled parts included, so the per-frame cost of a long stroke grew with its whole length. - The settled outline blocks are kept as shared paths and returned as a new Overlay::SharedPath; a block gets a new path only when it is joined again. - The canvas keeps the mesh each shared path was drawn with, by the path's allocation and for the view, clip and stroke it was built for. A frame with the same path and view draws the kept mesh; a pan, zoom or rotation builds it once more. Lines outside the canvas get no triangles. A frame keeps only the meshes it draws. egui still copies each kept mesh into the frame's mesh on every repaint. The release sends the same path.blob and path.eraseRegion command as before. The round Eraser tip's line is unchanged. --- crates/tools/src/draw2/gesture.rs | 6 +- crates/tools/src/draw2/gesture/area.rs | 27 ++++-- crates/tools/src/draw2/gesture/tests.rs | 108 +++++++++++++++++++++++- crates/tools/src/lib.rs | 4 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 3 + crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/canvas/overlays.rs | 84 +++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 84 ++++++++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 311 insertions(+), 14 deletions(-) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 61396e01d9..aea1b7b17e 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -1516,9 +1516,9 @@ impl Tool for GestureTool { }; let mut area = self.area.take().filter(|a| a.fits(key, &self.points)).unwrap_or_else(|| AreaPreview::new(key, self.points[0])); area.step(&self.points, pressures); - for path in area.paths(&self.points) { - o.push(Overlay::Path { path, color, width: 1.0, dashed: false }); - } + let paths = area.paths(&self.points); + o.extend(paths.settled.into_iter().map(|path| Overlay::SharedPath { path, color, width: 1.0 })); + o.extend(paths.live.into_iter().map(|path| Overlay::Path { path, color, width: 1.0, dashed: false })); self.area_pending.set(area.pending(&self.points)); self.area.set(Some(area)); } else if let Some(pressures) = self.size_pressures() { diff --git a/crates/tools/src/draw2/gesture/area.rs b/crates/tools/src/draw2/gesture/area.rs index 0bc0f13f25..a56322db40 100644 --- a/crates/tools/src/draw2/gesture/area.rs +++ b/crates/tools/src/draw2/gesture/area.rs @@ -13,6 +13,7 @@ //! most [`JOIN_PIECES_PER_CALL`] pieces' worth. The grid holds at most [`LISTINGS`] listings. use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathData, PathEl, PathSeg, Point, SubPath}; use vectorcraft_pathops as po; @@ -84,10 +85,22 @@ pub(super) struct AreaWork { pub joined: usize, } +/// The paths of the outline to show ([`AreaPreview::paths`]). +pub(super) struct AreaPaths { + /// The settled pieces' outlines, joined [`BLOCK_PIECES`] at a time: a block that did not change + /// is the same shared path as in the call before. + pub settled: Vec>, + /// The growing last piece's outline and the thin line over the points not taken yet, drawn + /// over the settled ones. + pub live: Vec, +} + /// The joined outline of up to [`BLOCK_PIECES`] settled pieces. #[derive(Default)] struct Block { - path: BezPath, + /// Shared with the paths [`AreaPreview::paths`] returned: a new one only when the block is + /// joined again. + path: Arc, /// A piece in it changed since `path` was joined. stale: bool, } @@ -227,8 +240,9 @@ impl AreaPreview { /// The paths to show: the settled pieces' outlines, joined [`BLOCK_PIECES`] at a time, the /// growing last piece's and a thin line over the points of `pts` not taken yet. The joined /// paths with a changed piece are joined again, newest first, at most - /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined. - pub(super) fn paths(&mut self, pts: &[Point]) -> Vec { + /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined, the same + /// shared path each call. + pub(super) fn paths(&mut self, pts: &[Point]) -> AreaPaths { let settled = self.settled_count(); for b in (0..self.blocks.len()).rev() { if self.work.joined >= JOIN_PIECES_PER_CALL { @@ -243,9 +257,10 @@ impl AreaPreview { joined.extend(p.shown.elements().iter().copied()); } self.work.joined += pieces.len(); - self.blocks[b] = Block { path: joined, stale: false }; + self.blocks[b] = Block { path: Arc::new(joined), stale: false }; } - let mut out: Vec = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| b.path.clone()).collect(); + let settled_paths = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| Arc::clone(&b.path)).collect(); + let mut out = vec![]; if let Some(p) = self.pieces.get(settled).filter(|p| !p.shown.elements().is_empty()) { out.push(p.shown.clone()); } @@ -261,7 +276,7 @@ impl AreaPreview { } out.push(guide); } - out + AreaPaths { settled: settled_paths, live: out } } /// How many pieces no longer grow: all of them but a last one with room for more segments. diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index ece7a369bb..1c29ee77c4 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -2311,11 +2311,111 @@ fn the_blob_brush_and_the_eraser_outline_a_long_drag_a_bounded_amount_per_frame( } } +/// The settled paths of the outline of a long Blob Brush or Eraser drag (each the outline of up +/// to `BLOCK_PIECES` pieces) come back from one call to the next, at any zoom, as the same shared +/// paths, which the host draws again without copying or flattening them. A step of the drag +/// replaces only the paths of the pieces near it. +#[test] +fn the_settled_outline_of_a_long_drag_is_the_same_shared_path_each_frame() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + let settled = |o: &[Overlay]| -> Vec> { + o.iter().filter_map(|o| if let Overlay::SharedPath { path, .. } = o { Some(std::sync::Arc::clone(path)) } else { None }).collect() + }; + let kept = |was: &[std::sync::Arc], now: &[std::sync::Arc]| { + now.iter().filter(|n| was.iter().any(|w| std::sync::Arc::ptr_eq(w, n))).count() + }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("eraser", -60.0, 30.0, false), ("blobBrush", 0.0, 100.0, true), ("eraser", 45.0, 50.0, true)] + { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + let n = if pressure { 3000 } else { 6000 }; + long_area_drag(&mut t, &cx, n, |i| if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }); + drain_area(&t, &cx); + let first = settled(&t.overlays(&cx)); + assert!(first.len() > 1, "{id}: {} settled paths", first.len()); + for zoom in [1.0, 4.0, 1.0] { + let again = settled(&t.overlays(&ToolContext { zoom, ..cx })); + assert_eq!(again.len(), first.len(), "{id}"); + assert_eq!(kept(&first, &again), first.len(), "{id}: at zoom {zoom}, every settled path is the one shown before"); + } + // One step more along the drag: with Size set to Pressure each path holds 64 one-segment + // pieces, part of one turn of the drag, and the paths far from the step stay the ones shown + // before. (Without it a path holds 64 pieces of 32 segments, many turns round the circle.) + if !pressure { + continue; + } + let s = n as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, x, y) }); + drain_area(&t, &cx); + let after = settled(&t.overlays(&cx)); + let replaced = after.len() - kept(&first, &after); + assert!((1..=8).contains(&replaced), "{id}: {replaced} of {} settled paths replaced", after.len()); + } +} + +/// The outline a Blob Brush or Eraser drag shows does not change what its release sends: the same +/// drag sends the same command whether or not its outline was drawn along the way. +#[test] +fn drawing_the_outline_of_a_drag_does_not_change_what_its_release_sends() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("blobBrush", 30.0, 40.0, true), ("eraser", -60.0, 30.0, false), ("eraser", 45.0, 50.0, true)] + { + let release = |drawn: bool| { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + for i in 0..800 { + let s = i as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + let kind = if i == 0 { PointerKind::Down } else { PointerKind::Drag }; + let pressure = if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }; + t.pointer(&cx, &PointerEvent { pressure, ..PointerEvent::new(kind, x, y) }); + if drawn { + t.overlays(&cx); + } + } + if drawn { + drain_area(&t, &cx); + } + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0)) + }; + let (drawn, not_drawn) = (release(true), release(false)); + assert!(matches!(drawn.first(), Some(Action::Exec(..))), "{id}: {drawn:?}"); + assert_eq!(drawn, not_drawn, "{id}"); + } +} + /// Every flattened point of the paths `o` (the outline shown). fn shown_points(o: &[Overlay]) -> Vec<(Point, Point)> { let mut lines = vec![]; for o in o { - let Overlay::Path { path, .. } = o else { continue }; + let path = match o { + Overlay::Path { path, .. } => path, + Overlay::SharedPath { path, .. } => &**path, + _ => continue, + }; let mut pen = Point::ZERO; let mut start = Point::ZERO; vectorcraft_geom::kurbo::flatten(path.elements().iter().copied(), 0.01, |el| match el { @@ -2402,7 +2502,11 @@ fn a_long_jump_in_a_blob_brush_drag_is_outlined_a_bounded_amount_at_a_time() { let o = t.overlays(&cx); let b = o .iter() - .filter_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::Shape::bounding_box(path)) } else { None }) + .filter_map(|o| match o { + Overlay::Path { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(path)), + Overlay::SharedPath { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(&**path)), + _ => None, + }) .reduce(|a, b| a.union(b)); let Some(b) = b else { panic!("{o:?}") }; assert!((b.x0 + 0.5).abs() < 0.01 && (b.x1 - 20_000.5).abs() < 0.01, "{b:?}"); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..18614a9302 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -71,6 +71,10 @@ pub enum Overlay { SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, + /// A solid path preview that later calls return again, as the same shared `path`, for as long + /// as it does not change (the settled outline of a long Blob Brush or Eraser drag). The host + /// can keep what it built to draw it while `path` is the same allocation. + SharedPath { path: std::sync::Arc, color: [u8; 3], width: f32 }, /// A line segment (smart guide, handle line). Line { a: Point, b: Point, color: [u8; 3], dashed: bool }, /// An anchor square: filled = selected. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 412dbcb4ef..658068f79b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -85,6 +85,7 @@ impl VectorcraftApp { hidden_chars: Default::default(), spelling: Default::default(), selection_marks: Default::default(), + shared_paths: Default::default(), selection_box: None, selection_bounds: None, content_bounds: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 286b4d4d02..9ebf7d39b8 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,6 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, + /// The meshes the tools' shared paths (a long Blob Brush or Eraser drag's settled outline) were + /// drawn with in the last frame. A frame that does not draw them drops them ([`canvas::show`]). + pub shared_paths: canvas::SharedPaths, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and /// Measure Type by Glyph Bounds). pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option)>, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..210c0a7614 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -59,6 +59,7 @@ use input::handle_input; use outline::KEY_OUTLINE; #[cfg(test)] use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; +pub use overlays::SharedPaths; #[cfg(test)] use overlays::selected_anchor; use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; @@ -238,6 +239,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); // The selection's kept marks. Likewise, a frame that does not draw them drops them. let selection_marks = std::mem::take(&mut app.canvas.selection_marks); + // The meshes of the tools' shared paths. Likewise, a frame that does not draw the tools' + // overlays drops them. + let mut shared_paths = std::mem::take(&mut app.canvas.shared_paths); // A release ends the Image Trace panel's eye-button hold, also when the panel is no longer // drawn, so a later press elsewhere does not show the source image. if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) { @@ -409,7 +413,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { original_path_overlay(app, &painter, &xf); let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info); - draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); + draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs), &mut shared_paths); + app.canvas.shared_paths = shared_paths; if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); crate::panels::three_d::paint_graphic_handles(app, &painter, &xf); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index aefd9253e9..65a9807ce7 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,8 +1,14 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use egui::Mesh; +use egui::epaint::{TessellationOptions, Tessellator}; + use super::artboards::names_an_artboard; use super::input::marquee_through; use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors}; use super::selection_marks::{MarkOptions, SelectionMarks}; -use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; +use super::{BezPath, Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; @@ -350,7 +356,75 @@ pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &X app.ime_caret_rect = Some((r, r)); } -pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) { +/// The meshes the tools' shared paths ([`Overlay::SharedPath`]) were drawn with in the last frame +/// ([`crate::CanvasCache::shared_paths`]). Each is kept with its path, by the path's allocation, and +/// with the view, clip and stroke it was built for: a path that comes back unchanged at the same view +/// is drawn with the same mesh, without flattening or tessellating it again. A frame keeps only the +/// meshes it draws; a frame that draws no overlays drops them all ([`super::show`]). +#[derive(Default)] +pub struct SharedPaths { + pub(super) meshes: HashMap, +} + +pub(super) struct SharedMesh { + /// Held so that the allocation, the key of the map, is not reused while the mesh is kept. + path: Arc, + key: SharedKey, + pub(super) mesh: Arc, +} + +/// What a shared path's mesh depends on besides the path. +#[derive(Clone, Copy, PartialEq)] +struct SharedKey { + /// The view's mapping onto the screen ([`Xf::affine`]). + view: [f64; 6], + /// The painter's clip: the lines of the path outside it have no triangles in the mesh. + clip: egui::Rect, + stroke: Stroke, + pixels_per_point: f32, + tessellation: TessellationOptions, +} + +impl SharedPaths { + /// The mesh that draws `path` with `s` at the view `xf` with the painter `p`: the one kept in + /// this frame or in `last` (the last frame's) when it was built for the same, else a new one. + /// It is kept for the next frame. + fn mesh(&mut self, last: &mut HashMap, path: &Arc, xf: &Xf, s: Stroke, p: &egui::Painter) -> Arc { + let ctx = p.ctx(); + let key = SharedKey { + view: xf.affine().as_coeffs(), + clip: p.clip_rect(), + stroke: s, + pixels_per_point: ctx.pixels_per_point(), + tessellation: ctx.tessellation_options(|o| *o), + }; + let at = Arc::as_ptr(path) as usize; + let fits = |m: &SharedMesh| m.key == key && Arc::ptr_eq(&m.path, path); + if let Some(kept) = self.meshes.get(&at).filter(|m| fits(m)) { + return Arc::clone(&kept.mesh); + } + let kept = match last.remove(&at).filter(|m| fits(m)) { + Some(kept) => kept, + None => { + let font_image_size = ctx.fonts(|f| f.font_image_size()); + let mut tessellator = Tessellator::new(key.pixels_per_point, key.tessellation, font_image_size, vec![]); + tessellator.set_clip_rect(key.clip); + let mut mesh = Mesh::default(); + for line in to_screen_path(path, xf) { + tessellator.tessellate_shape(Shape::line(line, s), &mut mesh); + } + SharedMesh { path: Arc::clone(path), key, mesh: Arc::new(mesh) } + } + }; + let mesh = Arc::clone(&kept.mesh); + self.meshes.insert(at, kept); + mesh + } +} + +pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook, shared: &mut SharedPaths) { + // The meshes kept from the last frame; those this frame does not draw are dropped. + let mut last = std::mem::take(&mut shared.meshes); for o in overlays { match o { // Where the tools select: the document rectangle, turned with the view. @@ -388,6 +462,12 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: stroke_path(p, path, xf, s); } } + Overlay::SharedPath { path, color, width } => { + let mesh = shared.mesh(&mut last, path, xf, Stroke::new(*width, c32(*color)), p); + if !mesh.is_empty() { + p.add(Shape::mesh(mesh)); + } + } Overlay::Line { a, b, color, dashed } => { let s = Stroke::new(1.0, c32(*color)); let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..1f41c29419 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -17,6 +17,7 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], &t, overlays::HandleLook::of(&prefs), + &mut overlays::SharedPaths::default(), ); } }; @@ -61,6 +62,89 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { } } +/// A tool's shared path ([`Overlay::SharedPath`], the settled outline of a long Blob Brush or Eraser +/// drag) is drawn with the mesh kept from the last frame while the path and the view stay the same, +/// and that mesh is the one the plain path draws. Another path, even one with the same elements, or +/// a new view builds a new mesh, and a frame keeps only the meshes it draws. +#[test] +fn a_shared_tool_path_is_drawn_with_the_mesh_kept_while_it_and_the_view_stay() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::MediumDark); + let prefs = vectorcraft_engine::Prefs::default(); + let mut shared = overlays::SharedPaths::default(); + let frame = |overlays: &[Overlay], zoom: f64, shared: &mut overlays::SharedPaths| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom, center: Point::new(200.0, 150.0), rot: 0.3 }; + overlays::draw_overlays(ui.painter(), &xf, overlays, &t, overlays::HandleLook::of(&prefs), shared); + }); + out.textures_delta.clear(); + out.shapes + }; + let meshes = |shapes: &[egui::epaint::ClippedShape]| -> Vec> { + shapes.iter().filter_map(|c| if let Shape::Mesh(m) = &c.shape { Some(std::sync::Arc::clone(m)) } else { None }).collect() + }; + let one = |shapes: Vec| { + let found = meshes(&shapes); + assert_eq!(found.len(), 1, "one mesh"); + std::sync::Arc::clone(&found[0]) + }; + let mut path = BezPath::new(); + path.move_to((60.0, 80.0)); + path.curve_to((120.0, 20.0), (220.0, 260.0), (330.0, 120.0)); + path.close_path(); + path.move_to((150.0, 150.0)); + path.line_to((250.0, 160.0)); + let a = std::sync::Arc::new(path.clone()); + let b = std::sync::Arc::new(path.clone()); + let shared_of = |p: &std::sync::Arc| Overlay::SharedPath { path: std::sync::Arc::clone(p), color: [0x80, 0x80, 0x80], width: 1.0 }; + let first = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!first.vertices.is_empty()); + let again = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same path at the same view is drawn with the kept mesh"); + // The plain path draws the same triangles. + let plain = frame(&[Overlay::Path { path: path.clone(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }], 1.0, &mut shared); + assert!(shared.meshes.is_empty(), "a frame that draws no shared path keeps no mesh"); + let lines: Vec<_> = plain.into_iter().filter(|c| matches!(c.shape, Shape::Path(_))).collect(); + assert_eq!(lines.len(), 2, "a line for each subpath"); + let tessellated = ctx.tessellate(lines, ctx.pixels_per_point()); + let triangles: Vec<_> = tessellated + .into_iter() + .filter_map(|c| if let egui::epaint::Primitive::Mesh(m) = c.primitive { Some(m) } else { None }) + .flat_map(|m| m.vertices) + .map(|v| (v.pos, v.color)) + .collect(); + assert_eq!(triangles, first.vertices.iter().map(|v| (v.pos, v.color)).collect::>()); + // A dropped mesh, another allocation of the same path and a new view each build a new mesh. + let rebuilt = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&first, &rebuilt) && *first == *rebuilt); + let other = one(frame(&[shared_of(&b)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&rebuilt, &other) && *rebuilt == *other); + let zoomed = one(frame(&[shared_of(&b)], 2.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&other, &zoomed) && *other != *zoomed); + // Two paths in one frame: each keeps its own mesh, and only those two are kept. + let both = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + let both_again = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + assert_eq!(both.len(), 2); + assert!(both.iter().zip(&both_again).all(|(x, y)| std::sync::Arc::ptr_eq(x, y))); + assert!(std::sync::Arc::ptr_eq(&both[1], &zoomed), "b at zoom 2 was kept from its frame"); + assert_eq!(shared.meshes.len(), 2); + // A path off the canvas gets no triangles and draws nothing, and the lines of one partly off + // it are left out of its mesh. + let mut far = path.clone(); + far.apply_affine(vectorcraft_geom::Affine::translate((5000.0, 5000.0))); + let far = std::sync::Arc::new(far); + assert!(meshes(&frame(&[shared_of(&far)], 1.0, &mut shared)).is_empty()); + assert!(shared.meshes.values().all(|m| m.mesh.is_empty()) && shared.meshes.len() == 1); + let mut half = path.clone(); + half.move_to((5000.0, 5000.0)); + half.line_to((5100.0, 5000.0)); + let half = one(frame(&[shared_of(&std::sync::Arc::new(half))], 1.0, &mut shared)); + assert_eq!(*half, *first, "the subpath off the canvas adds no triangles"); +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..9afbca767b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1105,6 +1105,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). + The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). A click sets a focal point and a click on a segment adds a highlighted anchor there; Shift-click adds more, and a drag from any of them moves them all. The anchors that are not direct-selected stay in place and a closed path selected whole moves whole (VectorCraft's own rule, in `docs/mcp.md`). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). From abf93e31d74578f511e1ba2e7555afcc5a5b2f57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:23:59 -0800 Subject: [PATCH 15/45] Canvas: translucent art blends with the paper before the monitor profile With Use Monitor Profile for Canvas converting, the art's raster was converted on its own and then drawn over the separately converted paper, so a translucent fill or an antialiased edge showed blend(convert(art), convert(paper)) rather than the converted blend. Each pixel with partial alpha over an artboard's paper, a colored artboard, the transparency grid or Hide Artboards' paper now gets the color that, drawn over the converted paper, shows the art blended with the paper in sRGB and then converted. Its alpha is kept, so a grid drawn in back still shows through. Opaque and empty pixels, art over the pasteboard and Outline convert as before, and with no profile to convert into nothing changes. The frame rendered whole, the part an edit renders again, the render worker's frame (and the pan fill), and the other windows' panes all use the same rule. The paper is part of the canvas cache key, so turning the grid on or hiding the artboards renders the art again. --- crates/ui-egui/src/caches.rs | 11 + crates/ui-egui/src/canvas.rs | 61 ++++- crates/ui-egui/src/canvas/panes.rs | 13 +- crates/ui-egui/src/canvas/raster.rs | 3 +- crates/ui-egui/src/canvas/tests.rs | 202 ++++++++++++++ crates/ui-egui/src/display_profile.rs | 377 +++++++++++++++++++++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 8 files changed, 653 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 286b4d4d02..8c6d669e14 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -164,6 +164,17 @@ pub struct CacheKey { /// The display profile the frame is converted into ([`crate::display_profile::DisplayProfile::key`]), /// 0 for none. pub display: u64, + /// The paper under the art that translucent art is blended with before the conversion + /// ([`crate::display_profile::convert_over`]). None without a display profile, and in Outline. + pub backdrop: Option>, +} + +impl CacheKey { + /// [`Self::backdrop`] with the zoom its grid's cells are measured at, as + /// [`crate::display_profile::convert_over`] takes it. + pub(crate) fn over(&self) -> Option<(&crate::display_profile::Backdrop, f64)> { + self.backdrop.as_deref().map(|b| (b, self.zoom)) + } } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..36e842dbaf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -623,6 +623,7 @@ fn cache_key( trace_source: app.ui.trace_hold_source.clone(), // Preferences › Performance › Use Monitor Profile for Canvas. display: app.display_profile.key(app.session.prefs.monitor_profile_display), + backdrop: backdrop(app, st, app.outline_view()), } } @@ -887,9 +888,10 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let renderer = &mut app.canvas.renderer; - let mut part = - effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); - crate::display_profile::convert(display.as_deref(), &mut part.pixels); + let part_view = Affine::translate((-f64::from(x0), -f64::from(y0))) * view; + let mut part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, part_view, &opts)); + let width = part.width; + crate::display_profile::convert_over(display.as_deref(), &mut part.pixels, width, part_view, key.over()); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -927,7 +929,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { let renderer = &mut app.canvas.renderer; let mut img = effects.view(|| renderer.render(doc, w, h, view, &opts)); effects.first_view_done(); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(display.as_deref(), &mut img.pixels, width, view, key.over()); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -982,13 +985,11 @@ fn paint_paper( let on_display = |c: Color32| crate::display_profile::color32(display.as_deref(), c); if !shows_artboards(app) { // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). - let setup = &doc.setup; - painter.rect_filled(rect, 0.0, on_display(if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE })); + painter.rect_filled(rect, 0.0, on_display(paper_color(&doc.setup))); } else { let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent) - .then(|| checker_texture(ui.ctx(), setup, display.as_deref())); - let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; + let grid = shows_transparency_grid(st).then(|| checker_texture(ui.ctx(), setup, display.as_deref())); + let paper = paper_color(setup); for ab in &doc.artboards { let q = xf.quad(ab.rect); // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). @@ -998,14 +999,52 @@ fn paint_paper( match &grid { Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { - let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, on_display(fill), Stroke::NONE)); + painter.add(Shape::convex_polygon(q, on_display(artboard_paper(ab, paper)), Stroke::NONE)); } } } } } +/// The color of the paper: Simulate Colored Paper's, else white. +fn paper_color(setup: &vectorcraft_doc::DocSetup) -> Color32 { + if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE } +} + +/// The color of artboard `ab`'s paper, the paper `paper`: an artboard with a background color +/// multiplies it with the paper, each sRGB channel artboard × paper / 255, rounded. +fn artboard_paper(ab: &vectorcraft_doc::Artboard, paper: Color32) -> Color32 { + ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper) +} + +/// The transparency grid covers the artboards of document `st`: it is shown, and the Background +/// Contents are transparent. +fn shows_transparency_grid(st: &vectorcraft_engine::DocState) -> bool { + st.transparency_grid && st.doc.setup.background == vectorcraft_doc::Background::Transparent +} + +/// The paper [`paint_paper`] draws for document `st`, which translucent art is blended with before +/// the display profile's conversion. None without a display profile, and in Outline (`outline`), +/// whose white canvas is the interface's. +fn backdrop(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, outline: bool) -> Option> { + if outline || app.display_profile.transform(app.session.prefs.monitor_profile_display).is_none() { + return None; + } + let rgb = |c: Color32| [c.r(), c.g(), c.b()]; + let setup = &st.doc.setup; + let paper = paper_color(setup); + let b = if shows_artboards(app) { + crate::display_profile::Backdrop { + everywhere: None, + artboards: st.doc.artboards.iter().map(|ab| (ab.rect, rgb(artboard_paper(ab, paper)))).collect(), + grid: shows_transparency_grid(st).then(|| (setup.grid_colors.map(|c| rgb(crate::panels::c32(&c))), setup.grid_size.cell())), + } + } else { + crate::display_profile::Backdrop { everywhere: Some(rgb(paper)), artboards: vec![], grid: None } + }; + Some(std::sync::Arc::new(b)) +} + /// The last rendered frame, placed through the current view while a new one is on its way. fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) { if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 76fb599fda..aee571d0ce 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -11,7 +11,7 @@ use super::grid::LineLook; use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ - Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, + Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key, dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; @@ -29,7 +29,7 @@ type Slot = (u64, u32); pub struct Panes { /// Renders off the UI thread (None: on it, as on wasm). worker: Option>, - worker_started: bool, + pub(crate) worker_started: bool, /// Renders on the UI thread when there is no worker. renderer: Option, /// By slot: the render shown and what it shows. @@ -109,13 +109,14 @@ impl Panes { if !current { let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts, job_display) = (doc.clone(), opts.clone(), display.clone()); + let (job_doc, job_opts, job_display, job_key) = (doc.clone(), opts.clone(), display.clone(), key.clone()); queued = worker.submit( slot, (slot, key.clone()), Box::new(move |r| { let mut img = r.render(&job_doc, w, h, view, &job_opts); - crate::display_profile::convert(job_display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(job_display.as_deref(), &mut img.pixels, width, view, job_key.over()); Some(img) }), ); @@ -127,7 +128,8 @@ impl Panes { } if !queued { let mut img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(display.as_deref(), &mut img.pixels, width, view, key.over()); self.upload(ctx, slot, key, &img); } } @@ -328,6 +330,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); + key.backdrop = backdrop(app, st, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index f57a15a795..2f9a4f981f 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -226,7 +226,8 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option *kept = keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); // The kept frame stays sRGB, so the next frame's copied pixels are converted only once. - crate::display_profile::convert(job.display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(job.display.as_deref(), &mut img.pixels, width, job.view, job.key.over()); WorkerFrame { img, moved, reused } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..791415bee0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2717,6 +2717,140 @@ fn the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on() assert_eq!(count(&mut app, red), Some(srgb)); } +/// With a display profile, translucent art and the anti-aliased edges of art over the paper are +/// blended with the paper in sRGB before the conversion: drawn over the converted paper, each such +/// pixel shows the converted blend. Opaque art, and art over the pasteboard, convert as before. The +/// part an edit renders again and the render worker's frame do the same, a change of the +/// transparency grid renders the art again, and Outline converts the art alone. +#[test] +fn translucent_art_over_the_paper_is_blended_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread + let half = app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + let disc = app.run("shape.ellipse", json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [disc], "color": "#2040c0"})).unwrap(); + // Left of the artboard, over the pasteboard. + let outside = app.run("shape.rectangle", json!({"x": -60, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [outside], "color": "#00ff00"})).unwrap(); + app.run("transparency.set", json!({"ids": [outside], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // The art's raster one frame uploads: where it goes ([x, y] for a part), its width and its pixels. + let upload = |app: &mut VectorcraftApp| -> Option<(Option<[usize; 2]>, usize, Vec)> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref()?.id(); + let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + (d.pos, img.width(), img.pixels.clone()) + }); + delta.clear(); + up + }; + upload(&mut app).expect("the first frame"); + let v = app.view_mut().unwrap(); + (v.zoom, v.center) = (1.0, Point::new(120.0, 150.0)); + let (_, width, srgb) = upload(&mut app).expect("the view at 100%"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let (_, _, shown) = upload(&mut app).expect("drawn again, converted"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_some()); + let white = t.apply8([255; 3]); + // What the canvas shows of pixel `c` over the converted paper `under`, as egui blends it. + let over = |c: egui::Color32, under: [u8; 3]| { + let k = 255 - u32::from(c.a()); + [0, 1, 2].map(|i| u32::from(c.to_array()[i]) + (u32::from(under[i]) * k + 127) / 255) + }; + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let (view_w, view_h) = (key.w, key.h); + let (_, _, view) = frame_view( + None, + (view_w, view_h), + &View { zoom: key.zoom, center: Point::new(key.cx, key.cy), rotation: key.rot, ..View::default() }, + key.ppp, + ); + let to_doc = view.inverse(); + let (mut blended, mut beside) = (0, 0); + for (i, (s, c)) in srgb.iter().zip(&shown).enumerate() { + let p = to_doc * Point::new((i % width) as f64 + 0.5, (i / width) as f64 + 0.5); + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before, at {p:?}"); + } else if (1.0..399.0).contains(&p.x) && (1.0..299.0).contains(&p.y) { + let k = 255 - u32::from(s.a()); + let mix = [0, 1, 2].map(|j| (u32::from(s.to_array()[j]) + (255 * k + 127) / 255).min(255) as u8); + let want = t.apply8(mix); + let got = over(*c, white); + assert!((0..3).all(|j| got[j].abs_diff(u32::from(want[j])) <= 1), "at {p:?}: {got:?} shown, {want:?} wanted"); + blended += 1; + } else if p.x < -1.0 { + assert_eq!(*c, plain(*s), "over the pasteboard the art converts alone, at {p:?}"); + beside += 1; + } + } + assert!(blended > 1000 && beside > 500, "{blended} pixels over the paper, {beside} over the pasteboard"); + // The test tells the two apart: the fill converted alone, over the converted paper, shows another color. + let at = view * Point::new(200.0, 150.0); + let at = at.y as usize * width + at.x as usize; + let (s, c) = (srgb[at], shown[at]); + assert!((50..=205).contains(&s.a()), "the 50% fill at the center: {s:?}"); + let alone = over(plain(s), white); + assert!((0..3).any(|j| alone[j].abs_diff(over(c, white)[j]) > 2), "{alone:?} and {:?}", over(c, white)); + // The render worker's frame of the same key is the same. + let opts = app.canvas.shown.as_ref().unwrap().1.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let display = app.display_profile.transform(true); + let job = |key: &CacheKey, doc: &std::sync::Arc| crate::render_worker::Job { + key: key.clone(), + doc: doc.clone(), + w: view_w, + h: view_h, + view, + opts: opts.clone(), + scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: Default::default(), + may_copy: false, + may_keep: false, + display: display.clone(), + }; + let worker = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let near = |a: &[u8], b: egui::Color32| a.iter().zip(b.to_array()).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(worker.pixels.as_chunks::<4>().0.iter().zip(&shown).all(|(a, b)| near(a, *b)), "the worker's frame matches"); + // An edit renders its part again, blended too. + app.run("paint.setFill", json!({"ids": [half], "color": "#0000ff"})).unwrap(); + let (pos, part_w, part) = upload(&mut app).expect("the edit shows"); + let [x0, y0] = pos.expect("only its part"); + let key = app.canvas.key.clone().unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let whole = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let whole = whole.pixels.as_chunks::<4>().0; + for (i, c) in part.iter().enumerate() { + let at = (y0 + i / part_w) * view_w as usize + x0 + i % part_w; + assert!(near(&whole[at], *c), "the part matches the whole frame at {at}: {c:?}, {:?}", whole[at]); + } + assert!(part.iter().filter(|c| c.a() != 0 && c.a() != 255).count() > 1000, "the blue fill is in the part"); + // The transparency grid replaces the paper under the art: drawn again. + app.run("view.transparencyGrid", json!({"on": true})).unwrap(); + assert!(upload(&mut app).is_some_and(|(pos, ..)| pos.is_none()), "the grid renders the art again"); + assert!(app.canvas.key.as_ref().and_then(|k| k.backdrop.as_ref()).is_some_and(|b| b.grid.is_some())); + // Outline: the white canvas is the interface's, so the art converts alone. + app.run("view.outline", json!({})).unwrap(); + upload(&mut app).expect("Outline renders again"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.outline && k.backdrop.is_none())); +} + /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template /// draws it again, dimmed as before; each re-renders at once. #[test] @@ -5527,6 +5661,74 @@ fn a_pane_draws_its_own_views_outline() { } } +/// With a display profile, a split-view pane or floating window blends translucent art with the +/// paper before the conversion, as the canvas does, and in its own Outline converts the art alone. +#[test] +fn a_pane_blends_translucent_art_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let half = app.run("shape.rectangle", json!({"x": 50, "y": 25, "width": 100, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + crate::doc_windows::new_window(&mut app).unwrap(); + app.canvas.panes.worker_started = true; // every pane render on this thread + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let ctx = egui::Context::default(); + let other = (0, 1); + let slot = (app.session.documents()[0].uid, 1); + // The pane's raster one frame uploads, and the key it was rendered for. + let pane = |app: &mut VectorcraftApp| -> (Vec, CacheKey) { + let cell = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + panes::pane(app, ui, other, cell); + }); + let (key, tex) = app.canvas.panes.shown.get(&slot).expect("the pane's render"); + let id = tex.id(); + let pixels = out.textures_delta.set.iter().find(|e| e.0.eq(&id)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + let key = key.clone(); + out.textures_delta.clear(); + (pixels.expect("the pane uploads"), key) + }; + app.session.prefs.monitor_profile_display = false; + let (srgb, key) = pane(&mut app); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + app.session.prefs.monitor_profile_display = true; + let (shown, key) = pane(&mut app); + assert!(key.backdrop.is_some(), "the pane's Preview blends with its paper"); + assert_eq!(srgb.len(), shown.len()); + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let mut blended = 0; + for (s, c) in srgb.iter().zip(&shown) { + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before"); + } else { + assert_eq!(c.a(), s.a(), "the alpha is kept"); + let p = plain(*s); + if (0..3).any(|j| c.to_array()[j].abs_diff(p.to_array()[j]) > 2) { + blended += 1; + } + } + } + assert!(blended > 100, "{blended} translucent pixels blended with the paper"); + // The pane's own Outline: the art converts alone, while the current view stays in Preview. + crate::doc_windows::view_mut(&mut app, other).unwrap().outline = true; + app.view_mut().unwrap().outline = false; + let (_, key) = pane(&mut app); + assert!(key.outline && key.backdrop.is_none(), "the pane's Outline has no paper to blend with"); +} + /// While the rulers' origin is dragged out of their corner, its crosshair draws over the canvas in /// the theme's dim text color in Preview, and in Outline in the dark gray that shows on the white /// canvas. diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs index 486489e6fe..af3c756e78 100644 --- a/crates/ui-egui/src/display_profile.rs +++ b/crates/ui-egui/src/display_profile.rs @@ -1,6 +1,8 @@ //! Preferences › Performance › Use Monitor Profile for Canvas: the canvas's art, its artboards' //! paper and the transparency grid are converted from sRGB into the ICC profile of the display -//! that shows most of the main window, as the last step before they are drawn. +//! that shows most of the main window, as the last step before they are drawn. Translucent art +//! and the anti-aliased edges of art over the paper or the grid are blended with it in sRGB first, +//! and the blend is converted ([`convert_over`]). //! //! The desktop app reads the display's profile on a background thread and hands the reading to //! [`DisplayProfile::report`], which checks the profile and builds its conversion table there. @@ -14,6 +16,7 @@ use std::sync::{Arc, RwLock}; use serde_json::{Value, json}; use vectorcraft_color::cms::display::DisplayTransform; pub use vectorcraft_color::cms::display::MAX_PROFILE_BYTES; +use vectorcraft_geom::{Affine, Point, Rect}; /// What the desktop app read for the display showing the main window. #[derive(Clone, Debug, PartialEq)] @@ -221,6 +224,220 @@ pub(crate) fn convert(t: Option<&DisplayTransform>, pixels: &mut [u8]) { t.apply_premultiplied(pixels); } +/// The document's paper under the canvas's art, which the canvas draws converted into the display +/// profile before the art: [`convert_over`] blends translucent art with it before converting. +/// The pasteboard and Outline's white canvas are interface colors and are not part of it. +#[derive(Clone, Debug, PartialEq)] +pub struct Backdrop { + /// View › Hide Artboards: the sRGB color of the paper that covers the whole canvas. + pub(crate) everywhere: Option<[u8; 3]>, + /// The artboards in the order they are drawn (the last on top), each with its paper's sRGB color. + pub(crate) artboards: Vec<(Rect, [u8; 3])>, + /// The transparency grid, which covers every artboard's paper: its two sRGB colors, and its cell + /// in screen points, counted from each artboard's top left corner. + pub(crate) grid: Option<([[u8; 3]; 2], f32)>, +} + +/// A color of the backdrop: in sRGB, and as the canvas shows it, converted. +#[derive(Clone, Copy, PartialEq)] +struct Paper { + srgb: [u8; 3], + shown: [u8; 3], +} + +/// An artboard of the backdrop, as [`Over`] looks it up. +struct Board { + /// Its rectangle in the document, normalized. + rect: Rect, + /// The corner the transparency grid's cells are counted from. + origin: Point, + paper: Paper, +} + +/// No artboard at a pixel of a row ([`Over::owners`]). +const NO_BOARD: u32 = u32::MAX; + +/// The first pixel at or after `x` that no artboard has taken yet, in the chain `next` (each +/// pixel's next candidate; a pixel not taken is its own), shortening the chain on the way. +fn untaken(next: &mut [u32], x: usize) -> usize { + let mut root = x; + while next[root] as usize != root { + root = next[root] as usize; + } + let mut at = x; + while next[at] as usize != root { + let n = next[at] as usize; + next[at] = root as u32; + at = n; + } + root +} + +/// [`Backdrop`] converted for a frame: what [`convert_over`] needs for each pixel. +struct Over<'a> { + t: &'a DisplayTransform, + /// The frame's pixels → the document. + to_doc: Affine, + everywhere: Option, + boards: Vec, + /// The grid's two colors and its cell in document points. + grid: Option<([Paper; 2], f64)>, +} + +impl<'a> Over<'a> { + /// None when the backdrop has no paper, or `view` (the document → the pixels) can't be undone. + fn new(t: &'a DisplayTransform, b: &Backdrop, view: Affine, zoom: f64) -> Option { + let det = view.determinant(); + if (b.everywhere.is_none() && b.artboards.is_empty()) || !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let paper = |srgb: [u8; 3]| Paper { srgb, shown: t.apply8(srgb) }; + let boards = b.artboards.iter().map(|&(r, color)| Board { rect: r.abs(), origin: r.origin(), paper: paper(color) }).collect(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + Some(Self { + t, + to_doc: view.inverse(), + everywhere: b.everywhere.map(paper), + boards, + grid: b.grid.map(|([a, c], cell)| ([paper(a), paper(c)], (f64::from(cell) / zoom).max(5e-10))), + }) + } + + /// The pixels `start..end` of the row whose centers lie at height `y` that artboard `b` covers, + /// their centers inside its rectangle. None when it covers none of the row's `width` pixels. + fn span(&self, b: &Board, y: f64, width: usize) -> Option<(usize, usize)> { + let at = self.to_doc * Point::new(0.0, y); + let [dx, dy, ..] = self.to_doc.as_coeffs(); + // Where along the row the document point stays within the rectangle, axis by axis. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for (start, slope, min, max) in [(at.x, dx, b.rect.x0, b.rect.x1), (at.y, dy, b.rect.y0, b.rect.y1)] { + if slope == 0.0 { + if !(min <= start && start < max) { + return None; + } + } else { + let (t0, t1) = ((min - start) / slope, (max - start) / slope); + (lo, hi) = (lo.max(t0.min(t1)), hi.min(t0.max(t1))); + } + } + // Pixel i's center is i + 0.5. + let (start, end) = ((lo - 0.5).ceil().max(0.0), (hi - 0.5).ceil().min(width as f64)); + (start < end).then_some((start as usize, end as usize)) + } + + /// `owner` becomes the artboard on top at each of the `width` pixels of the row whose centers + /// lie at height `y` ([`NO_BOARD`] where there is none). Each pixel is taken once, by the + /// topmost artboard over it, so a row costs its width plus its artboards however they overlap. + fn owners(&self, y: f64, width: usize, owner: &mut Vec, next: &mut Vec) { + owner.clear(); + owner.resize(width, NO_BOARD); + next.clear(); + next.extend(0..=width as u32); + for (k, b) in self.boards.iter().enumerate().rev() { + let Some((start, end)) = self.span(b, y, width) else { continue }; + let mut x = untaken(next, start); + while x < end { + owner[x] = k as u32; + next[x] = x as u32 + 1; + x = untaken(next, x + 1); + } + } + } + + /// The paper at pixel `i` of the row at height `y`, whose artboard on top is `owner`. + fn paper_at(&self, owner: u32, i: usize, y: f64) -> Option { + let b = self.boards.get(owner as usize)?; + Some(match &self.grid { + Some((colors, cell)) => { + let p = self.to_doc * Point::new(i as f64 + 0.5, y); + let cells = ((p.x - b.origin.x) / cell).floor() + ((p.y - b.origin.y) / cell).floor(); + colors[usize::from(cells.rem_euclid(2.0) >= 1.0)] + } + None => b.paper, + }) + } + + /// Rows `first_row..` of the frame, `width` pixels each, in `pixels`. + fn rows(&self, pixels: &mut [u8], first_row: usize, width: usize) { + let mut last_px = ([0u8; 4], [0u8; 4]); + let mut last_mix = (([0u8; 4], [0u8; 3]), [0u8; 4]); + let (mut owner, mut next) = (Vec::new(), Vec::new()); + for (j, line) in pixels.chunks_exact_mut(width * 4).enumerate() { + let y = (first_row + j) as f64 + 0.5; + // Found for the row's first translucent pixel. + let mut found = false; + for (i, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let a = px[3]; + if a == 0 { + continue; + } + let paper = if a == 255 { + None + } else if self.everywhere.is_some() { + self.everywhere + } else { + if !found { + self.owners(y, width, &mut owner, &mut next); + found = true; + } + self.paper_at(owner[i], i, y) + }; + match paper { + Some(p) if last_mix.0 == (*px, p.srgb) => *px = last_mix.1, + Some(p) => { + let input = (*px, p.srgb); + // Drawn over the converted paper, the pixel shows `px` over the paper in sRGB, converted. + let under = 255 - u32::from(a); + let mix = |c: usize| (u32::from(px[c]) + (u32::from(p.srgb[c]) * under + 127) / 255).min(255) as u8; + let shown = self.t.apply8([mix(0), mix(1), mix(2)]); + for ((o, s), u) in px.iter_mut().zip(shown).zip(p.shown) { + let paper_part = ((u32::from(u) * under + 127) / 255) as i32; + *o = (i32::from(s) - paper_part).clamp(0, 255) as u8; + } + last_mix = (input, *px); + } + None if last_px.0 == *px => *px = last_px.1, + None => { + let input = *px; + self.t.apply_premultiplied(px); + last_px = (input, *px); + } + } + } + } + } +} + +/// [`convert`], with each translucent pixel over the paper or the transparency grid of `backdrop` +/// blended with it in sRGB before the conversion. The canvas draws the art over the paper +/// converted: such a pixel keeps its alpha and takes the color that, drawn over the converted +/// paper, shows the blend converted. Opaque and fully transparent pixels, and pixels over the +/// pasteboard, convert as [`convert`] converts them. The pixels are rows of `width`; `view` maps the +/// document to them, and the backdrop's `zoom` is the canvas's screen points per document point, +/// in which the grid's cells are measured. +pub(crate) fn convert_over(t: Option<&DisplayTransform>, pixels: &mut [u8], width: u32, view: Affine, backdrop: Option<(&Backdrop, f64)>) { + let Some(t) = t else { return }; + let width = width as usize; + let over = backdrop.filter(|_| width > 0 && pixels.len().is_multiple_of(width * 4)).and_then(|(b, zoom)| Over::new(t, b, view, zoom)); + let Some(over) = over else { return convert(Some(t), pixels) }; + #[cfg(not(target_arch = "wasm32"))] + { + const PIECE: usize = 256 * 1024 * 4; + let threads = std::thread::available_parallelism().map_or(1, usize::from).min(16); + if threads > 1 && pixels.len() > PIECE { + let rows = (pixels.len() / (width * 4)).div_ceil(threads); + let over = &over; + std::thread::scope(|scope| { + for (k, piece) in pixels.chunks_mut(rows * width * 4).enumerate() { + scope.spawn(move || over.rows(piece, k * rows, width)); + } + }); + return; + } + } + over.rows(pixels, 0, width); +} + /// The opaque or translucent color `c` through `t`. pub(crate) fn color32(t: Option<&DisplayTransform>, c: egui::Color32) -> egui::Color32 { let Some(t) = t else { return c }; @@ -308,4 +525,162 @@ pub(crate) mod tests { let [r, g, b] = t.apply8([255, 0, 0]); assert_eq!(color32(Some(&t), egui::Color32::RED), egui::Color32::from_rgb(r, g, b)); } + + /// What the canvas shows of pixel `px` drawn over the converted paper `under`, as egui blends it. + fn shown_over(px: [u8; 4], under: [u8; 3]) -> [u32; 3] { + let k = 255 - u32::from(px[3]); + [0, 1, 2].map(|i| u32::from(px[i]) + (u32::from(under[i]) * k + 127) / 255) + } + + /// `px` blended with the sRGB paper `paper`, converted. + fn blended(t: &DisplayTransform, px: [u8; 4], paper: [u8; 3]) -> [u8; 3] { + let k = 255 - u32::from(px[3]); + t.apply8([0, 1, 2].map(|i| (u32::from(px[i]) + (u32::from(paper[i]) * k + 127) / 255).min(255) as u8)) + } + + fn assert_near(got: [u32; 3], want: [u8; 3], at: &str) { + assert!((0..3).all(|i| got[i].abs_diff(u32::from(want[i])) <= 1), "{at}: {got:?} shown, {want:?} wanted"); + } + + /// One row of `n` pixels, every one `px`. + fn row(n: usize, px: [u8; 4]) -> Vec { + px.repeat(n) + } + + #[test] + fn translucent_pixels_over_the_paper_are_blended_before_they_are_converted() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half_red = [128, 0, 0, 128]; + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(0.0, 0.0, 10.0, 1.0), [255; 3])], grid: None }; + let mut px = row(20, half_red); + px[12..16].copy_from_slice(&[255, 0, 0, 255]); + px[16..20].copy_from_slice(&[0; 4]); + convert_over(Some(&t), &mut px, 20, Affine::IDENTITY, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + let white = t.apply8([255; 3]); + assert_near(shown_over(at(0), white), blended(&t, half_red, [255; 3]), "over the paper"); + // Converted alone and drawn over the converted paper, the pixel shows another color. + let mut alone = half_red; + t.apply_premultiplied(&mut alone); + let want = blended(&t, half_red, [255; 3]); + assert!((0..3).any(|i| shown_over(alone, white)[i].abs_diff(u32::from(want[i])) > 2), "{alone:?}"); + assert_eq!(at(0)[3], 128, "the alpha is kept"); + // Opaque and empty pixels, and pixels over the pasteboard, convert as before. + let red = t.apply8([255, 0, 0]); + assert_eq!((at(3), at(4)), ([red[0], red[1], red[2], 255], [0; 4])); + assert_eq!(at(15), alone, "over the pasteboard"); + // Without a backdrop, and without a profile, as before. + let mut plain = row(20, half_red); + convert_over(Some(&t), &mut plain, 20, Affine::IDENTITY, None); + assert_eq!(plain, row(20, alone)); + let mut srgb = row(20, half_red); + convert_over(None, &mut srgb, 20, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(srgb, row(20, half_red)); + } + + #[test] + fn the_backdrop_is_found_through_the_view() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + // Papers far apart: a pixel blended with the wrong one shows a color several levels off. + let half = [128, 0, 0, 128]; + let (light, dark) = ([255, 230, 230], [40, 40, 30]); + // A later artboard is drawn over an earlier one. + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 100.0, 100.0), light), (Rect::new(50.0, 0.0, 100.0, 100.0), dark)], + grid: None, + }; + // A view turned a quarter clockwise and moved: pixel x runs down the document's y axis. + let view = Affine::translate((10.0, 0.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-20.0, -60.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + // Pixel (2.5, 0.5) is document point (20.5, 67.5): on the first artboard only. + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(2), t.apply8(light)), blended(&t, half, light), "on the first artboard"); + let view = Affine::translate((-60.0, -5.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(0), t.apply8(dark)), blended(&t, half, dark), "where the second covers the first"); + // View › Hide Artboards: the paper everywhere. + let all = Backdrop { everywhere: Some(dark), artboards: vec![], grid: None }; + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, Affine::translate((-5000.0, 0.0)), Some((&all, 1.0))); + assert_near(shown_over(px[..4].try_into().unwrap(), t.apply8(dark)), blended(&t, half, dark), "Hide Artboards"); + // Overlapping artboards under a turned and scaled view, against a direct lookup of the + // artboard on top whose rectangle holds each pixel's center. + let boards: Vec<(Rect, [u8; 3])> = (0..12u8) + .map(|k| { + let (x, y) = (f64::from(k) * 7.0, f64::from(k % 3) * 9.0); + (Rect::new(x, y, x + 15.0, y + 20.0), if k % 2 == 0 { [255, 255, 255 - k] } else { [20 + k, 20, 30] }) + }) + .collect(); + let b = Backdrop { everywhere: None, artboards: boards.clone(), grid: None }; + let view = Affine::rotate(0.3) * Affine::scale(1.7) * Affine::translate((5.0, -3.0)); + let (w, h) = (64, 48); + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match boards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), + } + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?}"); + } + assert!(on_paper > 500 && on_paper < w * h, "{on_paper} pixels on the artboards"); + } + + #[test] + fn the_transparency_grid_is_blended_cell_by_cell() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half = [128, 0, 0, 128]; + // Far apart, so that a pixel blended with the other cell's color shows several levels off. + let (light, dark) = ([255; 3], [40; 3]); + // Cells of 10 points from the artboard's corner at (5, 0), here 1 pixel a point. + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(5.0, 0.0, 65.0, 20.0), [255, 0, 0])], grid: Some(([light, dark], 10.0)) }; + let check = |zoom: f64, cells: [(usize, [u8; 3]); 3]| { + let mut px = row(60, half); + convert_over(Some(&t), &mut px, 60, Affine::IDENTITY, Some((&b, zoom))); + for (i, paper) in cells { + assert_near( + shown_over(px[i * 4..i * 4 + 4].try_into().unwrap(), t.apply8(paper)), + blended(&t, half, paper), + &format!("pixel {i} at {zoom}"), + ); + } + }; + check(1.0, [(6, light), (16, dark), (26, light)]); + // At 200% a cell is 5 document points. + check(2.0, [(6, light), (11, dark), (16, light)]); + } + + #[test] + fn large_frames_blend_the_same_on_several_threads() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 500.0, 900.0), [240, 230, 220])], + grid: Some(([[255; 3], [200; 3]], 8.0)), + }; + let (w, h) = (1000, 1000); + let mut big: Vec = (0..w * h * 4).map(|i| (i * 7 % 251) as u8).collect(); + for px in big.as_chunks_mut::<4>().0 { + let a = px[3]; + for c in &mut px[..3] { + *c = (*c).min(a); + } + } + let mut one = big.clone(); + Over::new(&t, &b, Affine::IDENTITY, 1.0).unwrap().rows(&mut one, 0, w); + convert_over(Some(&t), &mut big, w as u32, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(big, one); + } } diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..3893c4e542 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6010,8 +6010,9 @@ Color on (HDR or Automatic Color Management) and macOS windows are converted by again. In a Wayland session, and where no profile is set or the profile is not an RGB display profile of at most 1 MiB, the canvas stays sRGB. The app reads the display again every 10 seconds and when the window moves to another display. Exports, `ui.render`, the document, panels and thumbnails are not converted; with Proof Colors on, the proof is drawn -first and then converted. The art is converted before it is drawn over the converted paper, so the blend of a -translucent object or an antialiased edge with the paper can differ slightly from a conversion of the blended color. +first and then converted. Translucent art and the antialiased edges of art over an artboard's paper or the +transparency grid are blended with it in sRGB, and the blend is converted; over the pasteboard and in Outline the art +is converted on its own. `ui.inspect` returns `displayProfile`: `{enabled, display, source, profile, from, applied, reason, osManaged}`, where `source` is `profile`, `srgbProfile` (a profile that shows sRGB unchanged), `system`, `none`, `pending` or `unsupported`, `applied` says whether the canvas is converted, and `reason` says why not. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..c9a1e2ec34 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. +- **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. From 49d51e763d71f8d95398fada82926d90c6e2034b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 05:33:51 -0800 Subject: [PATCH 16/45] Canvas: the paper under an artboard's edge pixel follows that pixel's center The row span of each artboard took a row that runs backward across it (a mirrored or turned view) as holding its maximum edge and not its minimum one, and rounding in a sheared view could put a span end a pixel off. Each span now keeps which of its ends the rectangle holds, and each end is moved until it agrees with the test of the pixel's own center, so an edge pixel blends with the paper it lies on. --- crates/ui-egui/src/display_profile.rs | 93 +++++++++++++++++++-------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs index af3c756e78..be46424ec9 100644 --- a/crates/ui-egui/src/display_profile.rs +++ b/crates/ui-egui/src/display_profile.rs @@ -308,21 +308,52 @@ impl<'a> Over<'a> { fn span(&self, b: &Board, y: f64, width: usize) -> Option<(usize, usize)> { let at = self.to_doc * Point::new(0.0, y); let [dx, dy, ..] = self.to_doc.as_coeffs(); - // Where along the row the document point stays within the rectangle, axis by axis. - let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + // Where along the row the document point stays within the rectangle, axis by axis. The + // rectangle holds its minimum edge and not its maximum one, so a row that runs toward the + // minimum holds its end there and not its start: each bound keeps whether it is held. + let (mut lo, mut hi) = ((f64::NEG_INFINITY, true), (f64::INFINITY, true)); for (start, slope, min, max) in [(at.x, dx, b.rect.x0, b.rect.x1), (at.y, dy, b.rect.y0, b.rect.y1)] { if slope == 0.0 { if !(min <= start && start < max) { return None; } - } else { - let (t0, t1) = ((min - start) / slope, (max - start) / slope); - (lo, hi) = (lo.max(t0.min(t1)), hi.min(t0.max(t1))); + continue; + } + let (t0, t1) = ((min - start) / slope, (max - start) / slope); + let (from, to) = if slope > 0.0 { ((t0, true), (t1, false)) } else { ((t1, false), (t0, true)) }; + // The tighter bound wins, and of two equal bounds the one not held. + if from.0 > lo.0 || (from.0 == lo.0 && !from.1) { + lo = from; + } + if to.0 < hi.0 || (to.0 == hi.0 && !to.1) { + hi = to; } } - // Pixel i's center is i + 0.5. - let (start, end) = ((lo - 0.5).ceil().max(0.0), (hi - 0.5).ceil().min(width as f64)); - (start < end).then_some((start as usize, end as usize)) + // Pixel i's center is i + 0.5: the first pixel whose center is past `lo` (or on it, when + // held), up to the first one past `hi` (or on it, when not held). + let start = if lo.1 { (lo.0 - 0.5).ceil() } else { (lo.0 - 0.5).floor() + 1.0 }; + let end = if hi.1 { (hi.0 - 0.5).floor() + 1.0 } else { (hi.0 - 0.5).ceil() }; + let clamp = |v: f64| v.clamp(0.0, width as f64) as usize; + let (mut start, mut end) = (clamp(start), clamp(end)); + end = end.max(start); + // Rounding can put a bound a pixel away from where the rectangle's own test of each pixel's + // center puts it: move each end until the two agree. + let inside = |i: usize| b.rect.contains(self.to_doc * Point::new(i as f64 + 0.5, y)); + for _ in 0..2 { + if start < end && !inside(start) { + start += 1; + } else if start > 0 && inside(start - 1) { + start -= 1; + } + } + for _ in 0..2 { + if end > start && !inside(end - 1) { + end -= 1; + } else if end < width && inside(end) { + end += 1; + } + } + (start < end).then_some((start, end)) } /// `owner` becomes the artboard on top at each of the `width` pixels of the row whose centers @@ -615,27 +646,35 @@ pub(crate) mod tests { (Rect::new(x, y, x + 15.0, y + 20.0), if k % 2 == 0 { [255, 255, 255 - k] } else { [20 + k, 20, 30] }) }) .collect(); - let b = Backdrop { everywhere: None, artboards: boards.clone(), grid: None }; - let view = Affine::rotate(0.3) * Affine::scale(1.7) * Affine::translate((5.0, -3.0)); - let (w, h) = (64, 48); - let mut px = row(w * h, half); - convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); - let to_doc = view.inverse(); - let mut on_paper = 0; - for i in 0..w * h { - let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); - let mut want = half.to_vec(); - match boards.iter().rev().find(|(r, _)| r.contains(p)) { - Some(&(_, paper)) => { - let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; - convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); - on_paper += 1; + let check = |boards: &[(Rect, [u8; 3])], view: Affine, (w, h): (usize, usize)| { + let b = Backdrop { everywhere: None, artboards: boards.to_vec(), grid: None }; + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match boards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), } - None => t.apply_premultiplied(&mut want), + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?} through {view:?}"); } - assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?}"); - } - assert!(on_paper > 500 && on_paper < w * h, "{on_paper} pixels on the artboards"); + assert!(on_paper > 100 && on_paper < w * h, "{on_paper} pixels on the artboards"); + }; + check(&boards, Affine::rotate(0.3) * Affine::scale(1.7) * Affine::translate((5.0, -3.0)), (64, 48)); + // Rows that run backward across the artboards, with pixel centers on their edges: an edge + // at the minimum is on the artboard, one at the maximum is not. + check(&boards, Affine::new([-1.0, 0.0, 0.0, 1.0, 92.5, 0.0]), (96, 44)); + check(&boards, Affine::new([-1.0, 0.0, 0.0, -1.0, 92.5, 40.5]), (96, 44)); + // A sheared view where a computed bound falls on a pixel center that rounding puts just + // outside the artboard: the pixel's own center decides. + check(&[(Rect::new(-100.0, -2.0, 100.0, 100.0), [40, 40, 30])], Affine::new([3.0, 1.5, 0.1, 0.5, -0.3, 0.0]), (64, 48)); } #[test] From f7d1643a83de10e6598f837c6b6d49cceee38228 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:56:39 -0800 Subject: [PATCH 17/45] Live Paint: Make keeps the direction of gradient strokes along or across the path A stroke whose only setting that depends on direction is a gradient along or across the stroke now goes through the same edge fitting as dashed strokes, so each cut edge runs the way its source path does and the gradient keeps its direction. --- crates/engine/src/cmd/buildcmds.rs | 7 ++++--- crates/engine/src/tests/build.rs | 31 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 9e457212ad..13b7a3bd83 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -471,10 +471,11 @@ impl SourceStyle { /// How far, in points, a place on an edge may be from its source path and still be on it. const ON_SOURCE: f64 = 0.05; -/// Does stroke row `i` have settings that depend on where along its path a piece of it lies: -/// dashes, arrowheads, a width profile or a brush? +/// Does stroke row `i` have settings that depend on where along its path a piece of it lies or +/// which way it runs: dashes, arrowheads, a width profile, a brush, or a gradient along or across +/// the stroke? fn depends_on_place(i: &AppearanceItem) -> bool { - matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some()) + matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some() || s.gradient_mode != vectorcraft_doc::StrokeGradientMode::Within) } /// One subpath's segments with the length along it where each starts, then its whole length, and diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 8fae2eadd4..0614f63658 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,6 +2753,37 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } +/// When Make cuts a stroke whose only setting that depends on direction is a gradient along or +/// across it, each edge runs the way its source path does, so the gradient keeps its direction. +#[test] +fn live_paint_make_keeps_the_direction_of_gradient_strokes() { + use vectorcraft_doc::StrokeGradientMode; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + for (x0, x1) in [(-20.0, 120.0), (120.0, -20.0)] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (x0, 50.0), (x1, 50.0)); + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + set_rows(&mut s, l, vec![stroke_row(gradient, 2.0, |st| st.gradient_mode = mode)]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let pieces: Vec<(f64, f64)> = vectorcraft_tools::builder::edges(&node(&s, g)) + .iter() + .filter_map(|e| { + e.appearance.stroke().filter(|st| st.gradient_mode == mode)?; + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x)) + }) + .collect(); + assert_eq!(pieces.len(), 3, "{mode:?}, from {x0}"); + assert!( + pieces.iter().all(|&(p, q)| (q - p).signum() == (x1 - x0).signum()), + "{mode:?}, from {x0}: each piece runs the way the line does: {pieces:?}" + ); + } + } +} + /// Make gives each face every fill row of its source that draws, in order and with their /// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on /// the source; Expand keeps the rows, and Undo and Redo bring the group back with them. From 506f48daeab14cb05f1f9248383f5c7e0603e8ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:53:45 -0800 Subject: [PATCH 18/45] .ai save: live graphs are written as graph records Both .ai writers now write a graph that the .ai reader reads back as the same graph with its records, so a saved graph opens again live, with its table, type, axes and settings: %_Gs, the Gb, Gy, Gd, Ga/GA, Gz, Gc and GC records, the graph's group with each series' group followed by a Go tag, and %_GS (the published .ai file format specification, section 17 and appendix A). The records are the ones the reader in crates/eps/src/import/ai/read/graph.rs (#7311) reads, in the form it reads them. The shared encoder in crates/eps/src/ai_graph.rs decides first whether the reader keeps the graph whole: a column, stacked column, line or area graph with a legend, settings the records carry, a table of 1 to 10,000 categories and 1 to 256 series with finite values, labels of at most 255 bytes without control characters, and art the reader attaches the graph to. Any other graph is written as its art, with the existing warning, as is a graph whose art the editing writer's object limit cuts short. Tick-mark counts of 0 are written as 1, the count the reader keeps for both. Bar, stacked bar, scatter, pie and radar graphs, stacked column and area graphs whose series marks carry a stroke their legend swatch lacks, and line graphs with markers are still written as their art, with the warning. --- crates/engine/src/cmd/graph/tests.rs | 56 ++- crates/eps/src/ai_editing.rs | 4 + crates/eps/src/ai_editing/tests.rs | 70 +++ crates/eps/src/ai_editing/writer.rs | 58 ++- crates/eps/src/ai_graph.rs | 660 +++++++++++++++++++++++++++ crates/eps/src/legacy_ai.rs | 8 +- crates/eps/src/legacy_ai/tests.rs | 61 +++ crates/eps/src/legacy_ai/writer.rs | 34 +- crates/eps/src/lib.rs | 2 + docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 3 +- 11 files changed, 949 insertions(+), 10 deletions(-) create mode 100644 crates/eps/src/ai_graph.rs diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 0a788c4330..9f46cd8a22 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6769,8 +6769,8 @@ fn graph_commands_refuse_params_of_the_wrong_type() { } /// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A) -/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai` -/// says its data and settings aren't written, in either form. +/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`, +/// in either form, writes its records, and the file opens again with the graph's new data. #[test] fn a_graph_opened_from_ai_keeps_its_data_for_editing() { let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ @@ -6827,6 +6827,56 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() { } let r = s.execute("file.saveAs", &p).unwrap(); let notes = r["warnings"].as_array().cloned().unwrap_or_default(); - assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + assert!(!notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": dir.join(name)})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), 1, "{name}"); + let g = &graphs[0]; + assert_eq!( + (g.series.as_slice(), g.categories.as_slice(), g.rows.as_slice()), + (&after.series[..], &after.categories[..], &after.rows[..]), + "{name}" + ); + assert_eq!(g.kind, after.kind, "{name}"); } } + +/// A graph the graph tools draw, of each type, saved as `.ai` in either form, opens again as the same +/// graph exactly when the save gives no warning about graphs; a column graph does. +#[test] +fn a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns() { + let dir = vectorcraft_testkit::temp_dir("graph-ai-save"); + let mut live = vec![]; + for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,1,6"; + let id = s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(); + let written = spec(&s, NodeId(id)); + for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] { + let path = dir.join(format!("{ty}-{name}")); + let mut p = json!({"path": path, "format": "ai"}); + if let Some(v) = version { + p["aiVersion"] = json!(v); + } + let r = s.execute("file.saveAs", &p).unwrap(); + let notes = r["warnings"].as_array().cloned().unwrap_or_default(); + let warned = notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": path})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), usize::from(!warned), "{ty} {name}: {r}"); + if let Some(g) = graphs.first() { + assert_eq!((g.kind, g.cells()), (written.kind, written.cells()), "{ty} {name}"); + assert_eq!((g.series.as_slice(), g.categories.as_slice()), (&written.series[..], &written.categories[..]), "{ty} {name}"); + live.push(format!("{ty} {name}")); + } + } + } + assert!(live.contains(&"column v7.ai".to_string()) && live.contains(&"column pdf.ai".to_string()), "{live:?}"); +} diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0048cb945f..1834cc66b4 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -143,6 +143,10 @@ //! A path or compound path with a brush the reader reads live is written with an art style whose //! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads //! it back with its settings. +//! - Graphs, as the reader reads them (`read/graph.rs`): a graph on the layers whose records the +//! reader reads back as the same graph ([`crate::ai_graph`]) is written as its group between +//! `%_Gs`, its functional records and `%_GS`, each series' group tagged `Go` right after its end; +//! any other graph, and a graph with art left out, as its art, with a warning. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ce64e56fd5..f751cda156 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10606,3 +10606,73 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); } } + +/// A graph whose records read back whole is written with them, each series' group tagged before +/// its name, and opens again as the same live graph, also from a PDF-compatible file's first page. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + assert!(text.contains("U\n%_2 ") && text.contains(" 0 0 Go\n%_/ArtDictionary :\n"), "{what}: the tag comes before the series' name: {text}"); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read_back(&doc)); + assert_eq!(graphs.len(), 1, "{what}"); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let pdf = + vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); + let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap(); + let graphs = sample::graphs(&read); + assert_eq!(graphs.len(), 1); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = read_back(&doc); + assert!(sample::graphs(&read).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read), paths(&doc), "{what}: the graph's art is kept"); + } +} + +/// A graph with art left out for the object limit is written as the art there is room for, without +/// records or tags, with the warning; with room for all of it, it is written with its records. +#[test] +fn a_graph_with_art_left_out_is_written_without_records() { + use crate::ai_graph::sample; + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = crate::import::objects_read(&all.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert_eq!(room.program, all.program); + for max in [0, 1, read / 2, read - 1] { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!( + out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT) && out.warnings.iter().any(|w| w == writer::GRAPHS), + "{max}: {:?}", + out.warnings + ); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{max}: {text}"); + } +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 6361efe5ff..4d4653e42b 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -292,6 +292,13 @@ pub(super) struct Writer<'a> { shown: Vec, /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. windowed: Option, + /// Writing a graph's art with its records ([`Self::graph_records`]): a graph inside it is written + /// as its art. + in_graph: bool, + /// The `Go` line the next object, a graph's series group, is followed by. + series_tag: Option, + /// Where the `Go` lines of the graph being written start in [`Self::body`]. + graph_tags: Vec, } impl<'a> Writer<'a> { @@ -363,6 +370,9 @@ impl<'a> Writer<'a> { whole_images: HashMap::new(), shown: vec![], windowed: None, + in_graph: false, + series_tag: None, + graph_tags: vec![], } } @@ -638,6 +648,7 @@ impl<'a> Writer<'a> { /// One object: its state before it, its transparency, and its name and opacity mask after it. fn node(&mut self, n: &Node) { + let tag = self.series_tag.take(); if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } @@ -648,7 +659,8 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } - if n.extras().graph.is_some() && !self.in_look { + let graph = if n.extras().graph.is_some() && !self.in_look { self.graph_records(n) } else { None }; + if n.extras().graph.is_some() && !self.in_look && graph.is_none() { self.warn(GRAPHS); } // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its @@ -675,14 +687,18 @@ impl<'a> Writer<'a> { let start = self.body.len(); // An image's copy written as the part that its opacity mask, among other regions, shows. let mut mask_window = None; + let charged = self.charges(); match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { if self.open_group("u") { + self.in_graph |= graph.is_some(); for c in children { + self.series_tag = graph.as_ref().and_then(|g| g.tags.get(&c.id).cloned()); self.node(c); } + self.in_graph &= graph.is_none(); self.end_group("U", xy); } } @@ -785,6 +801,11 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; + // A graph's series group is tagged right after its end, before its dictionary. + if let Some(t) = tag { + self.graph_tags.push(self.body.len()); + self.line(&t); + } let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); @@ -798,6 +819,41 @@ impl<'a> Writer<'a> { if deform { self.line(&envelopes::plugin_line()); } + if let Some(g) = graph { + self.graph_end(g, start, charged); + } + } + + /// The records of graph `n` ([`crate::ai_graph::records`]) when it is written as a graph: on the + /// layers, outside art cut to a clipping object, symbols' art, live objects' art and another + /// graph, and without an art style anywhere in it. `None` when it is written as its art. + fn graph_records(&self, n: &Node) -> Option { + fn has_style(n: &Node, styled: &HashMap>) -> bool { + styled.contains_key(&n.id) || n.children().is_some_and(|c| c.iter().any(|c| has_style(c, styled))) + } + let placed = self.hidden == 0 && self.nest == 0 && self.drawn == 0 && self.clip.is_none() && !self.in_symbol && !self.in_graph; + if !placed || has_style(n, &self.styled) { + return None; + } + crate::ai_graph::records(n, self.to_art) + } + + /// The graph whose art was written from `start` on, charged from `charged`, ends: its records go + /// before the art and `%_GS` after it, or, when art in it was left out ([`Self::take_objects`]), + /// its `Go` lines are taken out and it stays its art, with a warning. + fn graph_end(&mut self, records: crate::ai_graph::Records, start: usize, charged: Charges) { + let tags = std::mem::take(&mut self.graph_tags); + if self.body.len() > start && !self.left_out_since(charged) { + self.body.insert_str(start, &records.head); + self.line(crate::ai_graph::END); + return; + } + for at in tags.into_iter().rev() { + if let Some(len) = self.body.get(at..).and_then(|rest| rest.find('\n')) { + self.body.replace_range(at..=at + len, ""); + } + } + self.warn(GRAPHS); } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. diff --git a/crates/eps/src/ai_graph.rs b/crates/eps/src/ai_graph.rs new file mode 100644 index 0000000000..4af8f7e3e9 --- /dev/null +++ b/crates/eps/src/ai_graph.rs @@ -0,0 +1,660 @@ +//! A live graph's records, which the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data +//! writer ([`crate::ai_editing`]) both write around a graph's group (Adobe Illustrator File Format +//! Specification, version 7.0, §17 and appendix A): `%_Gs`, the functional records on `%_` lines +//! (`Gb`, `Gy`, `Gd`, three `Ga` … `GA` axes, `Gz`, `Gc` cells and `GC`), the drawn art in one group +//! with each series' group followed by `%_2 column 0 0 Go`, then `%_GS`. +//! +//! A graph is written so only when the `.ai` reader (`import::ai::read::graph`) reads every record +//! back as the same graph: one of the four graph types it reads (grouped and stacked column, line, +//! area), the settings it keeps, a table it holds, and drawn art it attaches the graph to (each +//! series' group a direct member of the graph's, of paths painted with one solid fill and one plain +//! line, and the plot's left and bottom edges drawn as stroked lines). Any other graph is written as +//! its art, with a warning. + +use std::collections::HashMap; +use std::fmt::Write; + +use kurbo::{PathSeg, Point}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, Node, NodeId, NodeKind, PiePosition, + PieSort, TickLength, ValueAxisSide, +}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, Rect}; + +use crate::ps; + +/// Most cells of a graph's table, its label row and column included. +const MAX_CELLS: usize = 2_600_000; + +/// Longest cell string, in bytes (appendix A, `Gc`). +const MAX_CELL_TEXT: usize = 255; + +/// Most characters of the text before or after a value axis' numbers (appendix A, `Ga`, `GA`). +const MAX_AFFIX: usize = 9; + +/// Most tick marks per division the reader keeps. +const MAX_TICK_MARKS: usize = 20; + +/// Most divisions of a value axis with set values the reader keeps. +const MAX_DIVISIONS: usize = 100; + +/// How far an axis line may be from the plot's edge, in points. +const EDGE: f64 = 0.01; + +/// Most values one `Gc` takes. +const CELLS_PER_RECORD: usize = 15; + +/// A graph's records: what goes before its group (`%_Gs` to `%_GC`), and the `Go` line that follows +/// each series' group, by the group's id. `%_GS` follows the group. +pub(crate) struct Records { + pub(crate) head: String, + pub(crate) tags: HashMap, +} + +/// The line that ends a graph's records, after its group. +pub(crate) const END: &str = "%_GS"; + +/// The records of graph `n` (a group holding [`vectorcraft_doc::NodeExtras::graph`]), its art written +/// through `to_art`; `None` when the reader would not read them back as the same graph. +pub(crate) fn records(n: &Node, to_art: Affine) -> Option { + let g = n.extras().graph.as_deref()?; + let NodeKind::Group { children, clip: false } = &n.kind else { return None }; + (n.visible && n.extras().mask.is_none() && all_shown(n)).then_some(())?; + let ty = kind_code(g.kind)?; + settings_kept(g).then_some(())?; + let (left_manual, left_min, left_max, left_step) = axis(g.ticks, g.axis_min, g.axis_max)?; + let (right_manual, right_min, right_max, right_step) = axis(g.right_ticks, g.right_axis_min, g.right_axis_max)?; + // The table as the graph draws it: `cells` leaves out rows and series past the model's limits. + let rows = g.rows.len(); + let width = g.rows.iter().map(Vec::len).max().unwrap_or(0); + ((1..=MAX_GRAPH_CATEGORIES).contains(&rows) && (1..=MAX_GRAPH_SERIES).contains(&width)).then_some(())?; + let cells = g.cells(); + cells.iter().flatten().all(|v| v.is_none_or(f64::is_finite)).then_some(())?; + cells.iter().flatten().any(Option::is_some).then_some(())?; + let first_row = usize::from(!g.series.is_empty()); + let first_col = usize::from(!g.categories.is_empty()); + (g.series.is_empty() || g.series.len() == width).then_some(())?; + (g.categories.is_empty() || g.categories.len() == rows).then_some(())?; + let (total_rows, total_cols) = (rows + first_row, width + first_col); + total_rows.checked_mul(total_cols).filter(|c| *c <= MAX_CELLS)?; + g.series.iter().chain(&g.categories).all(|l| l.len() <= MAX_CELL_TEXT && !l.chars().any(char::is_control)).then_some(())?; + [&g.prefix, &g.suffix, &g.right_prefix, &g.right_suffix] + .iter() + .all(|a| a.chars().count() <= MAX_AFFIX && !a.chars().any(char::is_control)) + .then_some(())?; + // The plot, and the art the reader attaches the graph to. + let r = g.rect; + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(())?; + let plot = to_art.transform_rect_bbox(r); + (plot.is_finite() && plot.width() > 0.0 && plot.height() > 0.0).then_some(())?; + let tags = series_groups(children, width, first_col, g.kind == GraphKind::Line)?; + draws_axes(n, r).then_some(())?; + let placed = n.geometric_bounds()?; + (r.x0 >= placed.x0 - EDGE && r.y0 >= placed.y0 - EDGE && r.x1 <= placed.x1 + EDGE && r.y1 <= placed.y1 + EDGE).then_some(())?; + + let mut head = String::from("%_Gs\n"); + let _ = writeln!(head, "%_{} {} {} {} Gb", real(plot.x0), real(plot.y1), real(plot.x1), real(plot.y0)); + let flag = |b: bool| u8::from(b); + let _ = writeln!( + head, + "%_{ty} {} {} 14 {} {} 0 {} 6 44 Gy", + flag(g.drop_shadow), + flag(g.first_row_in_front), + flag(g.mark_points), + flag(g.connect_points), + flag(g.legend_across_top) + ); + let _ = + writeln!(head, "%_10 2 {} 0 {} {} {} Gd", flag(g.first_column_in_front), real(g.column_width), real(g.cluster_width), flag(g.edge_to_edge)); + // Tick marks per division: 0 and 1 both mean one, at the labels, and the reader reads 0 as 1. + let (category_marks, left_marks, right_marks) = (g.category_tick_marks.max(1), g.tick_marks.max(1), g.right_tick_marks.max(1)); + let _ = writeln!(head, "%_1 () Ga\n%_0 {} 0 0 0 {category_marks} {} () GA", tick(g.category_tick_length), flag(g.ticks_between_labels)); + let _ = writeln!(head, "%_2 {} Ga", ps::bytes(g.prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(left_manual), + tick(g.tick_length), + real(left_min), + real(left_max), + real(left_step), + left_marks, + ps::bytes(g.suffix.as_bytes()) + ); + let _ = writeln!(head, "%_4 {} Ga", ps::bytes(g.right_prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(right_manual), + tick(g.right_tick_length), + real(right_min), + real(right_max), + real(right_step), + right_marks, + ps::bytes(g.right_suffix.as_bytes()) + ); + let _ = writeln!(head, "%_{total_rows} {total_cols} {first_row} {first_col} Gz"); + let mut table = Table::new(&mut head); + if first_row == 1 { + if first_col == 1 { + table.text(""); + } + for s in &g.series { + table.text(s); + } + } + for (i, row) in cells.iter().enumerate() { + if let Some(c) = g.categories.get(i) { + table.text(c); + } + for v in row { + match v { + Some(v) => table.num(*v), + None => table.text(""), + } + } + } + table.finish(); + head.push_str("%_GC\n"); + Some(Records { head, tags }) +} + +/// The `Gc` records of a table, its cells in rows: at most [`CELLS_PER_RECORD`] values each, one +/// string at most. +struct Table<'a> { + out: &'a mut String, + values: usize, + string: bool, +} + +impl<'a> Table<'a> { + fn new(out: &'a mut String) -> Self { + Self { out, values: 0, string: false } + } + + fn start(&mut self, string: bool) { + if self.values == CELLS_PER_RECORD || (string && self.string) { + self.finish(); + } + self.out.push_str(if self.values == 0 { "%_" } else { " " }); + self.values += 1; + self.string |= string; + } + + fn text(&mut self, t: &str) { + self.start(true); + self.out.push_str(&ps::bytes(t.as_bytes())); + } + + fn num(&mut self, v: f64) { + self.start(false); + self.out.push_str(&real(v)); + } + + fn finish(&mut self) { + if self.values > 0 { + self.out.push_str(" Gc\n"); + } + (self.values, self.string) = (0, false); + } +} + +/// `v` as a PostScript number that reads back as the same value. +fn real(v: f64) -> String { + if v == 0.0 { + "0".into() + } else if (1e-4..1e15).contains(&v.abs()) { + format!("{v}") + } else { + format!("{v:e}") + } +} + +/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area; the reader reads no other type. +fn kind_code(k: GraphKind) -> Option { + match k { + GraphKind::Column => Some(5), + GraphKind::StackedColumn => Some(6), + GraphKind::Line => Some(7), + GraphKind::Area => Some(10), + _ => None, + } +} + +/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot. +fn tick(t: TickLength) -> u8 { + match t { + TickLength::None => 13, + TickLength::Short => 14, + TickLength::Full => 15, + } +} + +/// Whether `g`'s settings are ones the records give and the reader keeps: a legend beside or across +/// the top, every series of the graph's type, one value axis on the left, lines not filled, no graph +/// designs, label formatting or pie settings, column and cluster widths of 1% to 1000%, and at most +/// [`MAX_TICK_MARKS`] tick marks a division. +fn settings_kept(g: &GraphSpec) -> bool { + let percent = |p: f64| (1.0..=1000.0).contains(&p); + g.legend + && g.series_kinds.iter().all(|k| k.is_none_or(|k| k == g.kind)) + && g.value_axis == ValueAxisSide::Left + && !g.separate_scales + && g.right_series.is_empty() + && !g.filled_lines + && g.series_markers.iter().all(Option::is_none) + && g.series_columns.iter().all(Option::is_none) + && g.label_styles == LabelStyles::default() + && !g.pie_legend_in_wedges + && g.pie_position == PiePosition::Even + && g.pie_sort == PieSort::None + && percent(g.column_width) + && percent(g.cluster_width) + && [g.tick_marks, g.right_tick_marks, g.category_tick_marks].iter().all(|m| *m <= MAX_TICK_MARKS) +} + +/// A value axis' `useManualValues`, lowest and highest value and `betweenValue`: automatic (no tick +/// count and no values), or a whole number of divisions (1 to [`MAX_DIVISIONS`]) between a lowest +/// and a higher highest value, which the reader reads back as the same count. +fn axis(ticks: usize, min: Option, max: Option) -> Option<(bool, f64, f64, f64)> { + match (ticks, min, max) { + (0, None, None) => Some((false, 0.0, 0.0, 0.0)), + (1..=MAX_DIVISIONS, Some(lo), Some(hi)) if lo.is_finite() && hi.is_finite() && hi > lo => { + let step = (hi - lo) / ticks as f64; + let n = (hi - lo) / step; + let exact = step > 0.0 && step.is_finite() && (n - n.round()).abs() <= 1e-6 * n.round().max(1.0) && n.round() == ticks as f64; + exact.then_some((true, lo, hi, step)) + } + _ => None, + } +} + +/// Whether `n` and everything in it is shown. +fn all_shown(n: &Node) -> bool { + n.visible && n.children().is_none_or(|c| c.iter().all(|c| all_shown(c))) +} + +/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask or +/// effects: the reader keeps a series' paint only from groups and paths drawn so. +fn as_drawn(n: &Node) -> bool { + n.visible + && n.opacity == 1.0 + && n.blend == BlendMode::Normal + && !n.isolate + && n.extras().mask.is_none() + && !n.appearance.effects.iter().any(|e| e.visible) +} + +/// The visible fill and line `p` is written with as one path object: a solid fill, and a solid +/// plain line with its weight, each opaque, in Normal mode and without effects of its own. `None` +/// when it has other paint, more than one of either, or isn't one path of one subpath. +#[allow(clippy::type_complexity)] +fn plain_paint(p: &Node) -> Option<(Option, Option<(Paint, f64)>)> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &p.kind else { return None }; + (path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() == 1).then_some(())?; + let (mut fill, mut line) = (None, None); + for item in &p.appearance.items { + let drawn = match item { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }; + if !drawn { + // The writers leave out what draws nothing; the reader reads nothing in its place. + continue; + } + let plain = + item.opacity() == 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty() && matches!(item.paint(), Paint::Solid { .. }); + plain.then_some(())?; + match item { + AppearanceItem::Fill(f) if fill.is_none() && line.is_none() => fill = Some(f.paint.clone()), + AppearanceItem::Stroke(s) if line.is_none() && stroke::is_plain(s) => line = Some((s.paint.clone(), s.width)), + _ => return None, + } + } + Some((fill, line)) +} + +/// The `Go` line of each series' group among `children`: a group whose series index is one of the +/// `series` the table has, each once, of paths [`as_drawn`] with one fill and one line alike +/// ([`plain_paint`]), every path filled unless the graph draws lines (`line`). Its column is the +/// table's: after the label column when there is one (`first_col`). `None` when a series has no +/// such group. +fn series_groups(children: &[std::sync::Arc], series: usize, first_col: usize, line: bool) -> Option> { + let mut tags = HashMap::new(); + let mut seen = vec![false; series]; + for c in children { + let Some(s) = c.extras().series_index else { continue }; + let s = usize::try_from(s).ok().filter(|s| *s < series)?; + let slot = seen.get_mut(s)?; + (!*slot).then_some(())?; + *slot = true; + let NodeKind::Group { children: paths, clip: false } = &c.kind else { return None }; + (as_drawn(c) && *c.appearance == Appearance::default() && !paths.is_empty()).then_some(())?; + let mut paints = vec![]; + for p in paths { + as_drawn(p).then_some(())?; + paints.push(plain_paint(p)?); + } + let fill = paints.iter().find_map(|(f, _)| f.clone()); + paints.iter().all(|(f, _)| f.is_none() || *f == fill).then_some(())?; + (line || paints.iter().all(|(f, _)| f.is_some())).then_some(())?; + let stroke = paints.first().and_then(|(_, s)| s.clone()); + paints.iter().all(|(_, s)| *s == stroke).then_some(())?; + tags.insert(c.id, format!("%_2 {} 0 0 Go", s + first_col)); + } + seen.iter().all(|s| *s).then_some(tags) +} + +/// Whether `art` draws plot `plot`'s left edge and its bottom edge (the document's y runs down) as +/// straight lines, each a shown path of one subpath with a plain line ([`plain_paint`]) in a shown +/// group, as the reader finds the axes. +fn draws_axes(art: &Node, plot: Rect) -> bool { + let near = |a: Point, b: Point| (a - b).hypot() <= EDGE; + let on = |s: &PathSeg, a: Point, b: Point| match s { + PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)), + _ => false, + }; + let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1)); + let (mut left, mut bottom) = (false, false); + let mut stack = vec![art]; + while let Some(n) = stack.pop() { + if !n.visible || n.opacity <= 0.0 || (left && bottom) { + continue; + } + match &n.kind { + NodeKind::Group { children, clip: false } => stack.extend(children.iter().map(|c| &**c)), + NodeKind::Path { path, .. } if plain_paint(n).is_some_and(|(_, line)| line.is_some()) => { + for s in path.to_bezpath().segments() { + left |= on(&s, top_left, bottom_left); + bottom |= on(&s, bottom_left, bottom_right); + } + } + _ => {} + } + } + left && bottom +} + +/// Graphs built as the graph tools draw them, for the writers' tests. +#[cfg(test)] +pub(crate) mod sample { + use std::sync::Arc; + + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Document, GraphKind, GraphSpec, Node, PieSort, TickLength, ValueAxisSide}; + use vectorcraft_geom::{PathData, Point, Rect, shapes}; + + use super::Appearance; + + /// A graph of kind `kind`: series East and West, categories Q1 to Q3, West blank in Q2, on the + /// plot 100 100 300 250. + pub(crate) fn spec(kind: GraphKind) -> GraphSpec { + let mut g = GraphSpec { + kind, + rect: Rect::new(100.0, 100.0, 300.0, 250.0), + series: vec!["East".into(), "West".into()], + categories: vec!["Q1".into(), "Q2".into(), "Q3".into()], + ..GraphSpec::default() + }; + g.set_cells(vec![vec![Some(5.0), Some(3.0)], vec![Some(7.0), None], vec![Some(4.0), Some(6.0)]]); + g + } + + fn path(doc: &mut Document, p: PathData, fill: Paint, stroke: Paint) -> Arc { + Arc::new(Node::path(doc.alloc_id(), p, Appearance::basic(fill, stroke, 1.0))) + } + + /// Graph `g` drawn as a group: a group of each series' marks (a line graph's series as an unfilled + /// line, the others' as filled columns), its axes on the plot's left and bottom edges, and a + /// legend of a swatch for each series; its series groups named after the series. + pub(crate) fn graph(doc: &mut Document, g: GraphSpec) -> Node { + let r = g.rect; + let cells = g.cells(); + let width = cells.first().map_or(0, Vec::len); + let mut members = vec![]; + let axes = vec![ + path(doc, shapes::line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + path(doc, shapes::line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + ]; + members.push(Arc::new(Node::group(doc.alloc_id(), axes))); + for s in 0..width { + let gray = Paint::solid(Color::gray(0.2 + 0.3 * s as f32)); + let points: Vec = cells + .iter() + .enumerate() + .filter_map(|(c, row)| { + row.get(s).copied().flatten().map(|v| Point::new(r.x0 + 20.0 + 60.0 * c as f64 + 15.0 * s as f64, r.y1 - v * 10.0)) + }) + .collect(); + let marks: Vec> = if g.kind == GraphKind::Line { + let mut bp = kurbo::BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + vec![path(doc, PathData::from_bezpath(&bp), Paint::None, gray)] + } else { + points.iter().map(|p| path(doc, shapes::rectangle(Rect::new(p.x, p.y, p.x + 10.0, r.y1)), gray.clone(), Paint::None)).collect() + }; + let mut series = Node::group(doc.alloc_id(), marks); + series.name = Some(g.series.get(s).cloned().unwrap_or_else(|| format!("Series {}", s + 1))); + series.extras_mut().series_index = Some(s as u32); + members.push(Arc::new(series)); + } + let swatches: Vec> = (0..width) + .map(|s| { + let y = r.y0 + 12.0 * s as f64; + path( + doc, + shapes::rectangle(Rect::new(r.x1 + 10.0, y, r.x1 + 18.0, y + 8.0)), + Paint::solid(Color::gray(0.2 + 0.3 * s as f32)), + Paint::None, + ) + }) + .collect(); + members.push(Arc::new(Node::group(doc.alloc_id(), swatches))); + let mut n = Node::group(doc.alloc_id(), members); + n.extras_mut().graph = Some(Box::new(g)); + n + } + + /// A 400 × 300 document holding `g` drawn by [`graph`] on its layer. + pub(crate) fn document(g: GraphSpec) -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let n = graph(&mut doc, g); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc + } + + /// Graphs whose records read back whole: each type the records give, every setting they keep + /// changed, labels with parentheses, backslashes and non-ASCII characters, a label of 255 bytes, + /// and a table without labels. + pub(crate) fn kept() -> Vec<(&'static str, Document)> { + let mut cases = vec![ + ("column", document(spec(GraphKind::Column))), + ("stacked column", document(spec(GraphKind::StackedColumn))), + ("line", document(spec(GraphKind::Line))), + ("area", document(spec(GraphKind::Area))), + ]; + let mut g = spec(GraphKind::Column); + (g.drop_shadow, g.first_row_in_front, g.first_column_in_front, g.legend_across_top) = (true, true, true, true); + (g.mark_points, g.connect_points, g.edge_to_edge) = (false, false, true); + (g.column_width, g.cluster_width) = (120.0, 50.0); + (g.tick_length, g.tick_marks, g.ticks, g.axis_min, g.axis_max) = (TickLength::Full, 3, 4, Some(0.0), Some(20.0)); + (g.right_tick_length, g.right_tick_marks, g.right_ticks, g.right_axis_min, g.right_axis_max) = (TickLength::None, 2, 5, Some(1.0), Some(6.0)); + (g.category_tick_length, g.category_tick_marks, g.ticks_between_labels) = (TickLength::Short, 2, true); + (g.prefix, g.suffix, g.right_prefix, g.right_suffix) = ("($".into(), "\\)".into(), "é".into(), " u".into()); + cases.push(("settings", document(g))); + let mut g = spec(GraphKind::Column); + g.series = vec!["A (1)".into(), "B\\2".into()]; + g.categories = vec![String::new(), "naïve".into(), "a".repeat(255)]; + g.set_cells(vec![vec![Some(1.0 / 3.0), Some(1e-7)], vec![None, Some(2.5)], vec![Some(12.0), None]]); + cases.push(("labels", document(g))); + let mut g = spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + cases.push(("no labels", document(g))); + cases + } + + /// Graphs the records can't give as they are, which are written as their art: the five other + /// types, settings the reader doesn't keep, a table it can't hold, and art it can't attach the + /// graph to. + pub(crate) fn unkept() -> Vec<(&'static str, Document)> { + let with = |f: &dyn Fn(&mut GraphSpec)| { + let mut g = spec(GraphKind::Column); + f(&mut g); + document(g) + }; + let mut cases: Vec<(&'static str, Document)> = [GraphKind::Bar, GraphKind::StackedBar, GraphKind::Scatter, GraphKind::Pie, GraphKind::Radar] + .into_iter() + .map(|k| (k.id(), document(spec(k)))) + .collect(); + cases.extend([ + ("no legend", with(&|g| g.legend = false)), + ("filled lines", with(&|g| (g.kind, g.filled_lines) = (GraphKind::Line, true))), + ("a series of another type", with(&|g| g.series_kinds = vec![None, Some(GraphKind::Line)])), + ("a marker design", with(&|g| g.series_markers = vec![Some("Star".into())])), + ("a column design", with(&|g| g.series_columns = vec![Some(Default::default())])), + ("label formatting", with(&|g| g.label_styles.values = Some(Default::default()))), + ("a value axis on both sides", with(&|g| g.value_axis = ValueAxisSide::Both)), + ("a series on the right axis", with(&|g| g.right_series = vec![1])), + ("pie settings", with(&|g| g.pie_sort = PieSort::All)), + ("a manual axis without its values", with(&|g| g.ticks = 4)), + ("values without a tick count", with(&|g| (g.axis_min, g.axis_max) = (Some(0.0), Some(10.0)))), + ("101 divisions", with(&|g| (g.ticks, g.axis_min, g.axis_max) = (101, Some(0.0), Some(101.0)))), + ("too many tick marks", with(&|g| g.tick_marks = 21)), + ("a column width past 1000%", with(&|g| g.column_width = 1001.0)), + ("a long prefix", with(&|g| g.prefix = "0123456789".into())), + ("a line break in a label", with(&|g| g.series[0] = "East\nside".into())), + ("a label of 256 bytes", with(&|g| g.categories[0] = "a".repeat(256))), + ("a value that isn't finite", with(&|g| g.rows[0][0] = f64::NAN)), + ("only blank cells", with(&|g| g.set_cells(vec![vec![None, None]; 3]))), + ("fewer labels than series", with(&|g| g.series.truncate(1))), + ]); + let drawn = |what: &'static str, f: &dyn Fn(&mut Vec>)| { + let mut doc = Document::new(400.0, 300.0); + let mut n = graph(&mut doc, spec(GraphKind::Column)); + if let Some(c) = n.children_mut() { + f(c); + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + (what, doc) + }; + cases.extend([ + drawn("a series without its group", &|c| { + c.remove(2); + }), + drawn("no axes", &|c| { + c.remove(0); + }), + drawn("a hidden legend", &|c| { + if let Some(l) = c.last_mut() { + Arc::make_mut(l).visible = false; + } + }), + drawn("a series drawn half opaque", &|c| Arc::make_mut(&mut c[1]).opacity = 0.5), + drawn("a series mark with a gradient", &|c| { + let g = vectorcraft_color::Gradient::new( + vectorcraft_color::GradientKind::Linear, + vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)], + ); + let series = Arc::make_mut(&mut c[1]); + if let Some(marks) = series.children_mut() { + let mark = Arc::make_mut(&mut marks[0]); + mark.appearance = Appearance::basic(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))), Paint::None, 0.0).into(); + } + }), + ]); + cases + } + + /// The graphs in `doc`, each with the series index of its members. + pub(crate) fn graphs(doc: &Document) -> Vec<(GraphSpec, Vec>)> { + let mut out = vec![]; + doc.walk(|n| { + if let Some(g) = n.extras().graph.as_deref() { + out.push((g.clone(), n.children().map(|c| c.iter().map(|c| c.extras().series_index).collect()).unwrap_or_default())); + } + }); + out + } + + /// Whether `read` is the graph `written` was: the same data and settings, the plot in the same + /// place; its series' paint is the art's and its bounds are the art's as read. + pub(crate) fn same(written: &GraphSpec, read: &GraphSpec) -> bool { + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6; + let plain = |g: &GraphSpec| GraphSpec { + rect: Rect::ZERO, + rows: vec![], + blanks: vec![], + series_paints: vec![], + placed: None, + series_kinds: vec![], + ..g.clone() + }; + near(written.rect, read.rect) && written.cells() == read.cells() && plain(written) == plain(read) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Numbers are written so they read back the same, small and large ones included. + #[test] + fn numbers_read_back_as_written() { + for v in [0.0, 1.0, -2.5, 1.0 / 3.0, 1e-7, 123_456_789_012_345_678.0, f64::MAX, f64::MIN_POSITIVE] { + assert_eq!(real(v).parse::().ok(), Some(v), "{v}"); + } + } + + /// A graph of `categories` rows of `series` values, drawn by [`sample::graph`]. + fn sized(categories: usize, series: usize) -> Node { + let mut g = sample::spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + g.set_cells(vec![vec![Some(1.0); series]; categories]); + sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g) + } + + /// The table the reader holds: up to 10,000 categories and 256 series; past them, the graph is + /// its art. + #[test] + fn tables_up_to_the_readers_limits_are_written() { + assert!(records(&sized(MAX_GRAPH_CATEGORIES, 1), Affine::IDENTITY).is_some_and(|r| r.head.contains("%_10000 1 0 0 Gz\n"))); + assert!(records(&sized(MAX_GRAPH_CATEGORIES + 1, 1), Affine::IDENTITY).is_none()); + assert!(records(&sized(1, MAX_GRAPH_SERIES), Affine::IDENTITY).is_some_and(|r| r.tags.len() == MAX_GRAPH_SERIES)); + assert!(records(&sized(1, MAX_GRAPH_SERIES + 1), Affine::IDENTITY).is_none()); + } + + /// Tick-mark counts of 0, which mean one tick mark as 1 does, are written as 1, the count the + /// reader reads back. + #[test] + fn tick_mark_counts_of_0_are_written_as_1() { + let mut g = sample::spec(GraphKind::Column); + (g.tick_marks, g.right_tick_marks, g.category_tick_marks) = (0, 0, 0); + let n = sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g); + let head = records(&n, Affine::IDENTITY).map(|r| r.head).unwrap_or_default(); + let marks: Vec<&str> = head.lines().filter(|l| l.ends_with(" GA")).map(|l| l.split(' ').nth(5).unwrap_or("")).collect(); + assert_eq!(marks, ["1", "1", "1"], "{head}"); + } + + /// A table's cells go fifteen to a record, with one string at most in each. + #[test] + fn cells_go_fifteen_to_a_record_with_one_string_each() { + let mut out = String::new(); + let mut t = Table::new(&mut out); + t.text("a"); + t.text("b"); + for v in 0..16 { + t.num(f64::from(v)); + } + t.finish(); + let records: Vec<&str> = out.lines().collect(); + assert_eq!(records[0], "%_(a) Gc"); + assert_eq!(records[1], "%_(b) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 Gc"); + assert_eq!(records[2], "%_14 15 Gc"); + } +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 7e38b5ba51..f2146a8872 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -43,13 +43,17 @@ //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding //! strings are in. +//! - Graphs (§17): a graph whose records VectorCraft's reader reads back as the same graph +//! (`crate::ai_graph`) as `%_Gs`, its functional records, its group with each series' group +//! followed by `%_2 column 0 0 Go`, and `%_GS`. //! //! The format has one artboard, no transparency, sublayers, object names, hidden objects (only //! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write //! one file per artboard and expand appearances first; what is left is written as the format can //! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups, -//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern -//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical +//! live objects and symbols their art, other graphs their art, strokes a plain line can't draw +//! their outlines, pattern fills their tiles clipped to the shape, freeform gradients their average +//! colour, elliptical //! radial gradients circular (the gradient matrix is relative to a canvas the specification //! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of //! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 663e21bec6..dce22ec550 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -2122,3 +2122,64 @@ fn a_stained_instance_is_written_in_its_stained_color() { let text = String::from_utf8(write(&doc).bytes).unwrap(); assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}"); } + +/// A graph whose records read back whole is written with them (§17), and opens again as the same +/// live graph: its table, type and settings, its series' groups tagged in order, without a warning +/// about graphs. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{what}: {:?}", read.warnings); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } +} + +/// A graph inside a group is written with its records too, and opens on its own group. +#[test] +fn a_graph_in_a_group_is_written_with_its_records() { + use crate::ai_graph::sample; + let mut doc = Document::new(400.0, 300.0); + let graph = sample::graph(&mut doc, sample::spec(vectorcraft_doc::GraphKind::Column)); + let frame = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), red(), Paint::None); + let outer = Node::group(doc.alloc_id(), vec![Arc::new(frame), Arc::new(graph)]); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, outer).unwrap(); + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap(); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{:?}", read.warnings); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); + let layer = children(&read.document.layers[0]); + assert!(layer[0].extras().graph.is_none() && layer[0].children().is_some_and(|c| c.len() == 2), "{layer:#?}"); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning, and opens as that art. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); + assert!(out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + assert!(sample::graphs(&read.document).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read.document), paths(&doc), "{what}: the graph's art is kept"); + } +} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 0b41c3defa..d8f88e0d0b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -52,6 +52,8 @@ pub(super) struct Writer<'a> { last_image: Option<(ImageBlob, Option>)>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, + /// Writing a graph's art with its records: a graph inside it is written as its art. + in_graph: bool, } impl<'a> Writer<'a> { @@ -78,6 +80,7 @@ impl<'a> Writer<'a> { clips: vec![], last_image: None, whole_images: std::collections::HashMap::new(), + in_graph: false, } } @@ -264,7 +267,12 @@ impl<'a> Writer<'a> { if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() { self.warn(NAMES); } - if n.extras().graph.is_some() { + // A graph on the layers keeps its data and settings when its records read back whole. + let graph = match n.extras().graph.is_some() { + true if self.nest == 0 && !self.in_graph => crate::ai_graph::records(n, self.to_art), + _ => None, + }; + if n.extras().graph.is_some() && graph.is_none() { self.warn(GRAPHS); } let locked = locked || n.locked; @@ -273,7 +281,10 @@ impl<'a> Writer<'a> { self.warn(SUBLAYERS); self.group(children, *clip, locked); } - NodeKind::Group { children, clip } => self.group(children, *clip, locked), + NodeKind::Group { children, clip } => match &graph { + Some(records) => self.graph(children, locked, records), + None => self.group(children, *clip, locked), + }, NodeKind::Path { path, rule, guide, .. } => { self.lock(locked); if *guide { @@ -352,6 +363,25 @@ impl<'a> Writer<'a> { } } + /// A graph's group (`u` … `U`) between its records (§17): `%_Gs` and its functional records + /// before it, `%_GS` after it, and each series' group followed by its `Go` tag. + fn graph(&mut self, children: &[Arc], locked: bool, records: &crate::ai_graph::Records) { + self.lock(locked); + self.body.push_str(&records.head); + self.line("u"); + self.in_graph = true; + for c in children { + self.node(c, locked); + if let Some(tag) = records.tags.get(&c.id) { + self.line(tag); + } + } + self.in_graph = false; + self.lock(locked); + self.line("U"); + self.line(crate::ai_graph::END); + } + /// A clipping group: its first member's outline clips the others (`h W n`). That member's fills /// paint behind the art, inside the clip, and its strokes over it, outside. fn clip_group(&mut self, children: &[Arc], locked: bool) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..4cc0210e23 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -31,9 +31,11 @@ //! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries //! ([`export_ai_editing`], [`ai_private_data`]). //! - `ai_ops`: the path and line style operators those two write. +//! - `ai_graph`: the records of a live graph those two write around its group. //! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing. mod ai_editing; +mod ai_graph; mod ai_ops; pub mod dcs; mod import; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5ff38be13..57422e179a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -245,7 +245,8 @@ private editing data. VectorCraft: group is tagged once and paints its marks with one fill and one stroke, which the graph keeps as that series' paint; Graph Data then edits it. Other graphs (pie and scatter graphs, graphs with designs or customizations, records of later versions) come in as plain groups, paths and type, with a note. Saving a graph to - `.ai`, in either form, writes its art without its data or settings, with a warning. + `.ai`, in either form, writes these records around its art when the reader above reads them back as the same graph, + so the file opens again with its data and settings; any other graph is written as its art, with a warning. Legacy `.ai` patterns in the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches, with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Legacy `.ai` linked images in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..aec1fd696b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -614,6 +614,7 @@ since 2026-10-01. - **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. - **`.ai`/EPS graphic styles that paint with a named gradient:** a graphic style library's style (or a file's listed style) whose fill, or the fill or stroke of a style of one paint, paints with one of the file's gradients by name loads with that gradient at the style's angle, fitted to the bounds of each object it is applied to (VectorCraft's own placement; the style stores only the gradient's name and angle). A style naming a gradient the file doesn't define, or painting with a pattern, is still left out. Evidence: `fileio::tests_aiimport::graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it`, `style::tests::a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on`. Scores unchanged. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. +- **Graphs saved to `.ai` keep their data and settings:** a grouped column, stacked column, line or area graph is written to a `.ai` file, in either form, with the records the `.ai` reader reads (`crates/eps/src/import/ai/read/graph.rs`, #7311): `%_Gs`, its plot, type, settings, axes and table, its series' groups each tagged `Go`, and `%_GS` ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), §17 and appendix A). The file opens again with the same live graph. A graph with settings the reader doesn't keep, graph designs, label formatting, more than 10,000 categories or 256 series, or art the reader doesn't attach a graph to is written as its art, with a warning. `crates/eps/src/legacy_ai/tests.rs` and `crates/eps/src/ai_editing/tests.rs` write and read back each kind of graph; `a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns` checks every graph type the graph tools draw. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. @@ -1723,7 +1724,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). - A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). + A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note. Saving to `.ai` writes these records for a graph that reads back as the same graph, and warns that any other graph's data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). From 9954ad8a4d7e97de4e54eaee3c914e7df4aeb4d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:28:44 -0800 Subject: [PATCH 19/45] Canvas: the monitor profile conversion skips artboards outside the frame convert_over looked up every artboard of the document for each row with a translucent pixel, so a frame of 2,160 rows over a document with 1,000 artboards made about 2.16 million lookups. Over::new now takes the frame's size and keeps only the artboards whose rectangles meet the frame in the document, a pixel wider on every side. --- crates/ui-egui/src/display_profile.rs | 66 ++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs index be46424ec9..b3f10ab27e 100644 --- a/crates/ui-egui/src/display_profile.rs +++ b/crates/ui-egui/src/display_profile.rs @@ -285,18 +285,29 @@ struct Over<'a> { } impl<'a> Over<'a> { - /// None when the backdrop has no paper, or `view` (the document → the pixels) can't be undone. - fn new(t: &'a DisplayTransform, b: &Backdrop, view: Affine, zoom: f64) -> Option { + /// For a frame of `width` × `height` pixels. None when the backdrop has no paper, or `view` + /// (the document → the pixels) can't be undone. The artboards that lie wholly outside the + /// frame are left out, so a row looks only at the artboards in view. + fn new(t: &'a DisplayTransform, b: &Backdrop, view: Affine, zoom: f64, (width, height): (usize, usize)) -> Option { let det = view.determinant(); if (b.everywhere.is_none() && b.artboards.is_empty()) || !det.is_finite() || det.abs() < 1e-12 { return None; } let paper = |srgb: [u8; 3]| Paper { srgb, shown: t.apply8(srgb) }; - let boards = b.artboards.iter().map(|&(r, color)| Board { rect: r.abs(), origin: r.origin(), paper: paper(color) }).collect(); + let to_doc = view.inverse(); + // The frame in the document, a pixel wider on every side than its pixels' centers reach. + let frame = to_doc.transform_rect_bbox(Rect::new(-1.0, -1.0, width as f64 + 1.0, height as f64 + 1.0)); + let in_view = |r: &Rect| r.x0 <= frame.x1 && frame.x0 <= r.x1 && r.y0 <= frame.y1 && frame.y0 <= r.y1; + let boards = b + .artboards + .iter() + .map(|&(r, color)| Board { rect: r.abs(), origin: r.origin(), paper: paper(color) }) + .filter(|b| in_view(&b.rect)) + .collect(); let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; Some(Self { t, - to_doc: view.inverse(), + to_doc, everywhere: b.everywhere.map(paper), boards, grid: b.grid.map(|([a, c], cell)| ([paper(a), paper(c)], (f64::from(cell) / zoom).max(5e-10))), @@ -449,7 +460,9 @@ impl<'a> Over<'a> { pub(crate) fn convert_over(t: Option<&DisplayTransform>, pixels: &mut [u8], width: u32, view: Affine, backdrop: Option<(&Backdrop, f64)>) { let Some(t) = t else { return }; let width = width as usize; - let over = backdrop.filter(|_| width > 0 && pixels.len().is_multiple_of(width * 4)).and_then(|(b, zoom)| Over::new(t, b, view, zoom)); + let over = backdrop + .filter(|_| width > 0 && pixels.len().is_multiple_of(width * 4)) + .and_then(|(b, zoom)| Over::new(t, b, view, zoom, (width, pixels.len() / (width * 4)))); let Some(over) = over else { return convert(Some(t), pixels) }; #[cfg(not(target_arch = "wasm32"))] { @@ -718,8 +731,49 @@ pub(crate) mod tests { } } let mut one = big.clone(); - Over::new(&t, &b, Affine::IDENTITY, 1.0).unwrap().rows(&mut one, 0, w); + Over::new(&t, &b, Affine::IDENTITY, 1.0, (w, h)).unwrap().rows(&mut one, 0, w); convert_over(Some(&t), &mut big, w as u32, Affine::IDENTITY, Some((&b, 1.0))); assert_eq!(big, one); } + + #[test] + fn artboards_outside_the_frame_are_not_looked_up() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half = [128, 0, 0, 128]; + // A row of 1,000 artboards 30 points apart, each 20 wide and 40 high. + let artboards: Vec<(Rect, [u8; 3])> = (0..1000u32) + .map(|k| { + let x = f64::from(k) * 30.0; + (Rect::new(x, 0.0, x + 20.0, 40.0), [255 - (k % 200) as u8, 240, 230]) + }) + .collect(); + let b = Backdrop { everywhere: None, artboards: artboards.clone(), grid: None }; + // A turned frame of 64 × 48 pixels over the artboards near x = 15,000. + let view = Affine::rotate(0.4) * Affine::translate((-15_000.0, -5.0)); + let (w, h) = (64, 48); + let over = Over::new(&t, &b, view, 1.0, (w, h)).unwrap(); + assert!((1..=4).contains(&over.boards.len()), "{} artboards kept", over.boards.len()); + // Each pixel still uses the artboard its center is on. + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match artboards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), + } + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?}"); + } + assert!(on_paper > 100 && on_paper < w * h, "{on_paper} pixels on the artboards"); + // A frame over none of the artboards keeps none, and converts as without a backdrop. + let away = Affine::translate((0.0, -500.0)); + assert_eq!(Over::new(&t, &b, away, 1.0, (w, h)).map(|o| o.boards.len()), Some(0)); + } } From a4c513f101e11709788d42cc045e32c4721e9791 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:39:47 -0800 Subject: [PATCH 20/45] Live Paint: Make starts a closed edge where its path starts The edge built from a closed path that nothing cuts started at the anchor the planar map gave it, so dashes, a width profile or a brush began at a different place than on the path. The edge now starts at the anchor where its path starts. An edge with no anchor there takes its dashes from its own start and leaves off the profile and brush. The command description and the progress doc say that dashes aligned to corners and path ends are not fitted to cut edges. --- crates/engine/src/cmd/buildcmds.rs | 27 ++++++++++++++++++----- crates/engine/src/tests/build.rs | 35 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 13b7a3bd83..a0decb33d7 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path with the lengths set, without the fit of dashes aligned to corners and path ends, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -576,7 +576,9 @@ impl Track { /// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the /// edge's first segment places it, and its ends are taken where they lie that far along the source, /// so a path that passes a point twice is told apart. An edge that runs against the direction of its -/// source is reversed first. `None` when the edge is not a single piece found on the source. +/// source is reversed first, and a closed edge is started at the anchor where its source starts +/// (one with no anchor there does not count as running the whole subpath). `None` when the edge is +/// not a single piece found on the source. fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { use kurbo::{ParamCurve, ParamCurveArclen}; let [sp] = edge.subpaths.as_slice() else { return None }; @@ -621,13 +623,26 @@ fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, b let open = !track.closed; let starts = open && from <= ON_SOURCE; let ends = open && to >= track.len() - ON_SOURCE; - let whole = if open { starts && ends } else { edge_closed }; - Some((from, starts, ends, whole)) + if open { + return Some((from, starts, ends, starts && ends)); + } + // A closed edge runs the whole closed subpath, from the anchor the planar map starts it at. + // Start it at the anchor where the source starts, so settings that follow the path begin there. + let start = track.segs.first().map(|s| s.start())?; + let moved = edge_closed + && edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) { + Some(k) => { + sp.anchors.rotate_left(k); + true + } + None => false, + }); + Some((if moved { 0.0 } else { from }, false, false, moved)) } /// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says -/// (`None`: not known): its dashes go on from where the edge starts along the source, its -/// arrowheads stay only at the source's own ends, and its width profile and brush, which spread +/// (`None`: not known): its dashes go on from where the edge starts along the source, with the +/// lengths set and without the fit of dashes aligned to corners and path ends, its arrowheads stay only at the source's own ends, and its width profile and brush, which spread /// over the whole path, stay only on an edge that runs the whole path. fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) { let AppearanceItem::Stroke(s) = row else { return }; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0614f63658..960cab107e 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,6 +2753,41 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } +/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts +/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where +/// they do on the path, and it keeps the path's stroke row as it is. +#[test] +fn live_paint_make_starts_a_closed_edge_where_its_path_starts() { + use vectorcraft_doc::Dash; + let corners = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]; + for clockwise in [true, false] { + for start in 0..4 { + let mut s = session(); + let mut pts: Vec<(f64, f64)> = (0..4).map(|k| corners[(k + start) % 4]).collect(); + if !clockwise { + pts.reverse(); + } + let anchors: Vec<_> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + let [e] = edges.as_slice() else { panic!("one edge, got {}", edges.len()) }; + let sp = e.path_data().unwrap().subpaths.first().unwrap().clone(); + let at: Vec<(f64, f64)> = sp.anchors.iter().map(|a| ((a.p.x * 1e3).round() / 1e3, (a.p.y * 1e3).round() / 1e3)).collect(); + assert!(sp.closed); + assert_eq!(at, pts, "clockwise {clockwise}, start {start}"); + assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}"); + } + } +} + /// When Make cuts a stroke whose only setting that depends on direction is a gradient along or /// across it, each edge runs the way its source path does, so the gradient keeps its direction. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index da890f6079..11211e2c2e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1494,7 +1494,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. - Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. + Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path with the lengths set (dashes aligned to corners and path ends are not fitted to the cut edges), the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; each edge runs the way its path does, a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_starts_a_closed_edge_where_its_path_starts, live_paint_make_keeps_the_direction_of_gradient_strokes, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. From 53d28fc9bfc7693374e79e68551e7e80dbc6a214 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:33:14 -0800 Subject: [PATCH 21/45] Docs: the File formats row names the graphs .ai saves keep live The File formats area row no longer lists grouped column, stacked column, line and area graphs among the live objects a saved .ai writes as their art, and its evidence names the graph round-trip tests. --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aec1fd696b..b91aa68adf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -258,8 +258,8 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers row menu commands beyond Collect for Export and menus on guide and blend spine rows (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658) | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | From 69ce720e462a7e6644476f38908d3932c58dd7b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:44:26 -0800 Subject: [PATCH 22/45] Live Paint: Live Paint Selection does nothing on a triple-click main's Live Paint Selection double-click (#7342) replaced the tool's catch-all pointer arm with an explicit DoubleClick arm, so the TripleClick kind this branch adds left the match non-exhaustive. The tool now takes no action on TripleClick: the third click's press and release already select what a click selects, as before the merge. The merge also took main's import list in the engine's Live Paint commands, which no longer named Paint while this branch's Merge code still uses it; Paint is imported again. --- crates/engine/src/cmd/buildcmds/live_paint.rs | 6 +++--- crates/tools/src/builder/live_paint.rs | 2 ++ crates/tools/src/builder/tests.rs | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 90f495f9c9..60a7a584e7 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ - Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, - Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, - number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index f43ba27a2c..e42b2649de 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -907,6 +907,8 @@ impl Tool for LivePaintSelectionTool { (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } + // The third click's press and release already selected what a click selects. + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 08f269ac4e..28daa4fdc9 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -2506,6 +2506,22 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() { assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); } +#[test] +fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, ..], _) = live_strip(&mut d); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintSelectionTool::default(); + // The third click's press and release select the face under the pointer, as a click does. + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]); + for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] { + assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty()); + } +} + #[test] fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() { let mut d = Document::new(500.0, 500.0); From 7ef603db404699513b701d54c0750571ebb9ac0b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:24:56 -0800 Subject: [PATCH 23/45] Live Paint: bucket scopes follow the stroke an edge draws The Live Paint Bucket's connected and samePaint scopes kept their own copies of the face flood and the edge walk. They read an edge's stored stroke paint, so an edge that is fully transparent or whose stroke is hidden stopped a face flood and matched as stroked. The face flood also required outlines within 0.0001 pt of an edge, which a curved face that the planar map refits can miss. The engine now calls the shared flood_faces and connected_edges that Live Paint Selection uses (#7342), and compares edges by lp_stroke_paint, the paint of the topmost stroke that draws. The tool records that same paint as the match for a double-click or triple-click on an edge. The params text and docs/mcp.md describe the drawn stroke paint. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 245 +----------------- crates/engine/src/tests/build.rs | 35 +++ crates/tools/src/builder.rs | 4 +- crates/tools/src/builder/live_paint.rs | 4 +- crates/tools/src/builder/lookup.rs | 2 +- docs/mcp.md | 11 +- 7 files changed, 52 insertions(+), 251 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 738bd34750..7bf0dadfc6 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges whose stroke paint is match) | \"samePaint\" (a triple-click: also every edge of the group whose stroke paint is match), match?: paint (as the document stores it; scope connected or samePaint only; default: the edge's stroke paint), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges; when every edge it reaches already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges; when every edge it reaches already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", has_doc, lp_stroke_edge ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 60a7a584e7..2778e0495a 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -399,243 +399,6 @@ fn match_param(p: &Value, c: &str, allowed: bool, scopes: &str) -> Result