اذهب إلى المحتوى

أمثلة عن رسوميات فاخرة باستعمال جافا


رضوى العربي

لقد رأينا أمثلةً كثيرةً على طريقة استخدام كائن سياق رسومي من النوع GraphicsContext للرسم ضمن حاوية، ولكنه يمتلك في الحقيقة ميزات أخرى كثيرة إلى جانب تلك التي تناولناها من قبل. سنناقش خلال هذا القسم رسوم الحاوية، وسنبدأ بفحص تابعين مفيدين لإدارة حالة كائن سياق رسومي.

يمتلك كائنٌ g من النوع GraphicsContext خاصيات متعددة، مثل لون المِلء وعرض الخط، وتؤثر تلك الخاصيات على جميع ما يرسمه الكائن. من المهم أن تتذكر أن أي حاوية تملك كائن سياق رسومي وحيد، وأن أي تغيير يُجرَى على إحدى خاصيات ذلك الكائن، سيُطبَّق على جميع رسومه المُستقبلية إلى أن تتغير قيمة خاصياته مرةً أخرى؛ أي أن تأثير أي تغيير على خاصياته يتجاوز حدود البرنامج الفرعي subroutine الذي نُفَّذت خلاله. ومع ذلك، يحتاج المبرمجون عادةً إلى تغيير قيمة بعض الخاصيات تغييرًا مؤقتًا، بحيث تُعاد إلى قيمها السابقة بعد انتهائهم؛ ولهذا، تتضمَّن واجهة برمجة التطبيقات الخاصة بالرسوميات التابعين g.save()‎ و g.restore()‎ لتنفيذ ذلك بسهولة.

يُخزِّن التابع g.save()‎ عند تنفيذه حالة كائن السياق الرسومي، والتي تتضمَّن جميع الخاصيات التي تؤثر على الرسوم تقريبًا. في الواقع، يَملُك كائن السياق الرسومي مكدسًا stack.) للحالات -ألقِ نظرةً على مقال المكدس Stack والرتل Queue وأنواع البيانات المجردة ADT-، بحيث يُخزِّن التابع g.save()‎ حالة الكائن الحالية بالمكدس؛ وفي المقابل، يَسحَب التابع g.restore()‎ عند استدعائه الحالة الموجودة أعلى المكدس، ويَضبُط قيم جميع خاصيات الكائن لتتوافق مع القيم المخزَّنة بالحالة المسحوبة.

نظرًا لاستخدام كائن السياق الرسومي مكدس حالات، فمن الممكن استدعاء التابع save()‎ عدة مرات قبل استدعاء التابع restore()‎، ولكن لا بُدّ أن يُقابِل كل استدعاءٍ للتابع save()‎ استدعاءً للتابع ()restore. ومع ذلك، لا يؤدي استدعاء restore()‎ بدون استدعاء سابق مقابل للتابع save()‎ إلى حدوث خطأ؛ بل يحدث فقط تجاهُلٌ لتلك الاستدعاءات الإضافية.

يُعدّ استدعاء التابع save()‎ ببداية البرنامج الفرعي والتابع restore()‎ بنهايته، الطريقة الأسهل عمومًا لضمان عدم تجاوز التغييرات المُجراة على كائن السياق الرسومي ضمن برنامج فرعي معين ما يليه من استدعاءات.

تبرز أهمية حفظ حالة السياق الرسومي واستعادتها لاحقًا أثناء التعامل مع التحويلات transforms، والتي سنناقشها لاحقًا ضمن هذا القسم.

رسم حواف الأشكال بطريقة فاخرة

لقد رأينا طريقة رسم حواف الخطوط والمنحنيات وحتى خطوط المحارف النصية، وكذلك طريقة ضبط كُلٍ من لون وحجم الخط المُستخدَم لرسم تلك الحواف strokes. في الواقع، تتوفَّر خاصيات أخرى تؤثر على طريقة رسم الحواف؛ فيُمكِننا مثلًا أن نرسمها بخطوط مُنقطّة أو متقطعة، كما يُمكننا أن نتحكَّم بمظهر نهاية الحواف، وبمظهر نقطة التلاقي بين خيطين أو منحنين، مثل تقابُل جانبي مستطيل بإحدى أركانه. يحتوي كائن السياق الرسومي على خاصيات للتحكُّم بجميع تلك الخاصيات، ولكن من الضروري استخدام خطوطٍ عريضة بما يكفي حتى نتمكَّن من ملاحظة تاثير خاصيات، مثل تلك التي تتحكَّم بمظهر نهاية الحواف ونقط التلاقي. تَعرِض الصورة التالية بعض الخيارات المتاحة:

001Line_Attributes.png

تُبيّن نهايتي القطع المستقيمة الثلاثة على يسار الصورة الأنماط الثلاثة المحتملة للخط "cap.‎"، كما ستَجِد الحافة باللون الأسود؛ أما الخط الهندسي، فهو بلون أصفر داخل الحافة. عند استخدام النمط BUTT، تُقطَّع نهايتي الخط الهندسي؛ أما عند استخدام النمط الدائري ROUND، يُضاف قرصٌ بكل نهاية قطره يساوي عرض الخط؛ بينما عند استخدام النمط المربع SQUARE، يُضاف مربعٌ بدلًا من القرص. وما تحصل عليه عند استخدام النمط الدائري أو المربع هو نفس ما تحصل عليه عند رسم حافة بقلم رأسه دائري أو مربع على الترتيب.

إذا كان g كائن سياق رسومي، فسيضبُط التابع g.setLineCap(cap)‎ نمط الخط cap المُستخدَم لرسَم الحواف، إذ يَستقبِل التابع معاملًا من نوع التعداد StrokeLineCap المُعرَّف بحزمة javafx.scene.shape، والتي قيمه المحتملة هي StrokeLineCap.BUTT و StrokeLineCap.ROUND والقيمة الافتراضية StrokeLineCap.SQUARE.

تَعمَل نقط التلاقي بنفس الطريقة، إذ يَضبُط التابع g.setLineJoin(join)‎ مظهر النقطة التي يلتقي عندها خيطان أو منحنيان، ويَستقبِل التابع في تلك الحالة كائنًا من النوع StrokeLineJoin، وقيمه المحتملة هي القيمة الافتراضية StrokeLineJoin.MITER و StrokeLineJoin.ROUND و StrokeLineJoin.BEVEL؛ إذ تعرِض الصورة السابقة هذه الأنماط الثلاثة بالمنتصف.

عند اِستخدام النمط MITER، تمتد القطعتان المستقيمتان لتكوين نقطة حادة؛ أما عند اِستخدَام النمطين الآخرين، فسيُقطّع الركن. يكون التقطيع بالنسبة للنمط BEVEL باستخدام قطعة مستقيمة؛ أما بالنسبة للنمط ROUND، فسيكون التقطيع باستخدام قوس أو دائرة. تبدو نقط التلاقي الدائرية أفضل إذا رسمت منحنيًا كبيرًا بهيئة سلسلةٍ من القطع المستقيمة القصيرة.

يُمكِننا استخدام التابع g.setLineDashes()‎ لتطبيق نمط تقطيع يُظهِر الحواف على نحوٍ مُنقّط أو متقطِّع، إذ تُمثِّل معاملات هذا التابع أطوال القطع والمسافات الفاصلة بينها:

g.setLineDashes( dash1, gap1, dash2, gap2, . . . );

لاحِظ أن معاملات التابع هي من النوع double، ويُمكِن تمريرها أيضًا مثل مصفوفةٍ من النوع double[]‎. وإذا رسمنا حافةً معينةً بعد اختيار أيٍّ من أنماط التقطيع، فسيتكوّن الشكل من خطٍ أو منحنًى طوله يُساوِي dash1، متبوعًا بمسافةٍ فارغة طولها gap1، والتي يتبعها خط أو منحنى طوله dash2، وهكذا، وسيُعاد تكرار نفس نمط الخطوط والفراغات بما يكفي لرسم طول الحافة بالكامل.

على سبيل المثال، يرسِم الاستدعاء g.setLineDashes(5,5)‎ الحافة مثل متتاليةٍ من القطع القصيرة التي يبلُغ طول كل منها 5 ويَفصِل بينها مسافةً فارغةً طولها يُساوِي 5؛ بينما يَرسِم الاستدعاء g.setLineDashes(10,2)‎ متتاليةً من القطع المستقيمة الطويلة، بحيث يَفصِل بينها مسافات قصيرة. يُمكِن تخصيص نمطٍ مُكوَّن من قطعٍ مستقيمة ونقط باستدعاء التابع g.setLineDashes(10,2,2,2)‎، ويتكوَّن النمط المتقطع الافتراضي من خطٍ بدون أي نقاط أو فواصل.

يَسمح البرنامج التوضيحي StrokeDemo.java للمُستخدِم برسم خطوط ومستطيلات باستخدام تشكيلةٍ مختلفة من أنماط الخطوط. ألقِ نظرةً على الشيفرة المصدرية لمزيدٍ من التفاصيل.

تلوين فاخر

لقد أصبح بإمكاننا رسم حوافٍ فاخرة الآن. ربما لاحظت أن كل عمليات الرسم كانت مقيدةً بلونٍ واحدٍ فقط، ولكن يُمكِننا في الواقع تجاوز ذلك باستخدام الصنف Paint؛ إذ تُستخدَم كائنات هذا الصنف لإسناد لونٍ لكل بكسل نمرُ عليه أثناء الرسم. وفي الواقع، يُعدّ الصنف Paint صنفًا مجرّدًا abstract، وهو مُعرَّف بحزمة javafx.scene.paint. يُعدّ الصنف Color واحدًا فقط من ضمن الأصناف الفرعية الحقيقية المشتقة من الصنف Paint، أي يُمكِننا أن نُمرِّر أي كائن من النوع Paint مثل معاملٍ للتابعين g.setFill()‎ و g.setStroke()‎. وعندما يكون الكائن المُمرَّر من النوع Color، سيُطبَّق نفس اللون على جميع البكسلات التي تَمُرّ عبرها عملية الرسم، ولكن هنالك بالطبع أنواع أخرى يَعتمِد فيها اللون المُطبَّق على بكسلٍ معين على إحداثياته.

تُوفِّر مكتبة JavaFX عدة أصناف بهذه الخاصية، مثل ImagePattern، ونوعين آخرين للتلوين المُتدرِج؛ فبالنسبة للصنف الأول ImagePattern، يُستخرَج لون البكسل من صورة مكررة -إن اقتضت الضرورة- مثل ورق حائط حتى تُغطِي سطح المستوى xy بالكامل؛ أما بالنسبة للتلوين المُتدرِج، فيتغير اللون المُطبَّق على البكسلات تدريجيًا من لونٍ لآخر بينما ننتقل من نقطة لأخرى. تُوفِّر جافا صنفين من هذا النوع، هما LinearGradient و RadialGradient.

سيكون من المفيد لو اطلعنا على بعض الأمثلة، إذ تعرض الرسمة التالية مضلعًا ملونًا بأسلوبين مختلفين؛ ويَستخدِم المضلع الموجود على اليسار الصنف LinearGradient، بينما يَستخدِم المضلع الموجود على اليمين الصنف ImagePattern. لاحِظ أن اللون قد اُستخدَم هنا لملء المضلع ذاته فقط، أما حواف المُضلع فقد رُسمَت بلونٍ أسود عادي. ومع ذلك، يُمكِننا استخدام كائنات الصنف Paint لرسم حواف الأشكال وملئها أيضًا.

ستَجِد شيفرة رسم المضلعين بالبرنامج PaintDemo.java، إذ يُمكِّنك هذا البرنامج من الاختيار بين عدة أنماط تلوين مختلفة، إلى جانب التحكًّم ببعض من خاصيات تلك الأنماط.

002Paint_Demo.png

إذا اخترنا استخدام الصنف ImagePattern، فسنحتاج إلى صورة أولًا. لقد تعرَّضنا للصنف Image بالقسم الفرعي 6.2.3، وتعلمنا طريقة إنشاء كائن من النوع Image من ملف مورد. وبفرض أن pict هو كائنٌ يُمثِّل صورةً من النوع Image، يُمكِننا إنشاء كائنٍ من النوع ImagePattern باستخدام باني الكائن على النحو التالي:

patternPaint = new ImagePattern( pict, x, y, width, height, proportional );

تُمثِّل المعاملات x و y و width و height قيمًا من النوع double، تتحكَّم بكُلٍ من حجم الصورة وموضعها بالحاوية؛ إذ تُوضَع نسخةٌ واحدةٌ من الصورة بالحاوية، بحيث يقع ركنها الأيسر العلوي بنقطة الإحداثيات (x,y)، وتمتد وفقًا للطول والعرض المُخصَّصين. بعد ذلك، تتكرر الصورة أفقيًا ورأسيًا عدة مرات بما يكفي لملء الحاوية بالكامل، ولكنك ترى فقط الجزء الظاهر عبر الشكل المطلوب تطبيق نمط التلوين عليه.

يُمثِّل المعامل الأخير للباني proportional قيمةً من النوع boolean، وتُخصِّص طريقة تفسير المعاملات الأخرى x و y و width و height؛ فإذا كانت قيمة proportional تُساوِي false، فسيُقاس كُلٌ من width و height باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فسيُقاسان باستخدام مضاعفاتٍ من حجم الشكل المطلوب تطبيق نمط التلوين عليه، وستكون (x,y) مساويةً (0,0) في الركن الأيسر العلوي للشكل (بتعبير أدق، المستطيل المُتضمِّن للشكل). انظر ما يلي على سبيل المثال:

patternPaint = new ImagePattern( pict, 0, 0, 1, 1, true );

يُنشِيء هذا الباني كائنًا من النوع ImagePattern، بحيث تُغطِي نسخةٌ واحدةٌ من الصورة الشكل بالكامل. وإذا طبقنا نمط التلوين ذاك على عدة أشكال بأحجام مختلفة، فستمتد الصورة بما يتناسب مع كل شكل. وفي المقابل، إذا أردنا أن يحتوي الشكل على أربع نسخ من الصورة أفقيًا ونسختين رأسيًا، فيُمكِننا استخدام ما يلي:

patternPaint = new ImagePattern( pict, 0, 0, 0.25, 0.5, true );

في المقابل، يُحدَّد نمط التلوين المُتدرِج الخطي من خلال تخصيص قطعةٍ مستقيمة ولون عدة نقاط على طول تلك القطعة؛ إذ يُطلَق على تلك النقاط وألوانها اسم وقفات الألوان color stops، وتُضاف الألوان بينها، بحيث يُسنَد لونٌ معينٌ لكل نقطة على الخط، كما تمتد الألوان عموديًا على القطعة المستقيمة لتنتج شريطًا ملونًا لا نهائي.

ينبغي أيضًا أن نُخصِّص ما يحدث خارج ذلك الشريط، وهو ما يُمكِننا فعله بتخصيص ما يُعرَف باسم "أسلوب التكرار cycle method" بمكتبة JavaFX؛ إذ تشتمل قيمه المحتملة على مايلي:

  • الثابت CycleMethod.REPEAT، الذي يُكرِّر الشريط الملون بما يكفي لتغطية سطح المستوى بالكامل.
  • الثابت CycleMethod.MIRROR الذي يكرِّر أيضًا الشريط الملون، ولكنه يَعكِس كل تكرارٍ منه لتتوافق الألوان الموجودة على أطراف كل تكرار مع بعضها.
  • الثابت CycleMethod.NO_REPEAT، الذي يَمِدّ اللون الموجود على كل طرف لا نهائيًا.

تَعرِض الصورة التالية ثلاثة أنماط تلوين مُتدرِج تستخدِم جميعها نفس خاصيات القطعة المستقيمة ووقفات الألوان، إذ تَستخدِم تلك الموجودة على يسار الصورة أسلوب التكرار MIRROR؛ بينما تَستخدِم الموجودة بمنتصف الصورة أسلوب التكرار REPEAT؛ في حين تَستخدِم تلك الموجودة على اليمين أسلوب التكرار NO_REPEAT.

رسمنا القطعة المستقيمة ووضعنا علامات على مواضع وقفات الألوان على طول تلك القطعة، كما هو موضح في الشكل التالي:

003Linear_Gradient.png

يُنشِئ الباني التالي نمط تلوين متدرج:

linearGradient = new LinearGradient( x1,y1, x2,y2, proportional, cycleMethod,
                                                stop1, stop2, . . . );

تُمثِّل المعاملات الأربعة الأولى قيمًا من النوع double، إذ تُخصِّص نقطتي البداية والنهاية للقطعة المستقيمة (x1,y1) و (x2,y2)؛ أما المعامل الخامس proportional، فهو من النوع boolean؛ فإذا كانت قيمته تساوي false، فستُفسَّر نقطتي البداية والنهاية باستخدام نظام الإحداثيات المعتاد؛ أما إذا كانت قيمته تساوي true، فإنها تُفسَّر باستخدام نظام إحداثيات تقع نقطته (0,0) في الركن الأيسر العلوي للشكل المطلوب تطبيق نمط التلوين عليه، بينما تقع نقطته (1,1) في الركن الأيمن السفلي لنفس الشكل.

يُمثِّل المعامل السادس cycleMethod إحدى الثوابت CycleMethod.REPEAT و CycleMethod.MIRROR و CycleMethod.NO_REPEAT؛ بينما تشير المعاملات المتبقية إلى وقفات الألوان، ويُمثَل كل وقفةٍ منها كائنًا من النوع Stop.

يستقبل باني الصنف Stop معاملين من النوع double و Color؛ إذ يُخصِّص المعامل الأول مكان الوقفة على طول القطعة المستقيمة، وتكون قيمته نسبةً من المسافة بين نقطتي البداية والنهاية. في العموم، لا بُدّ أن يكون مكان الوقفة الأولى عند 0 ومكان الوقفة الأخيرة عند 1؛ كما لا بُدّ أن تكون قيمة مكان كل وقفة أكبر من قيمة مكان الوقفة التي تَسبِقها، إذ يُمكِننا مثلًا إنشاء نمط التلوين المُتدرِج المُستخدَم لتلوين الشكل الموجود على يسار الصورة السابقة على النحو التالي:

grad = new LinearGradient( 120,120, 200,180, false, CycleMethod.MIRROR,
                                  new Stop( 0,   Color.color(1, 0.3, 0.3) ), 
                                  new Stop( 0.5, Color.color(0.3, 0.3, 1) ), 
                                  new Stop( 1,   Color.color(1, 1, 0.3)   )  );

بالنسبة لنمط التلوين المُتدرِج الخطي، سيكون اللون ثابتًا على طول عدة خطوط معينة؛ أما بالنسبة لنمط التلوين الدائري، فسيكون اللون ثابتًا بالنسبة لدوائر معينة؛ إذ تُخصَّص وقفات الألوان على طول نصف قطر دائرة بأبسط حالات التلوين المتدرج الدائري، ويكون اللون ثابتًا للدوائر التي تتشارك نفس المركز. وبالمثل في نمط التلوين الخطي، إذ يُحدِّد أسلوب التكرار اللون المُستخدَم خارج الدائرة. في الحقيقة، الأمر أكثر تعقيدًا من ذلك، فقد يتضمن نمط التلوين الدائري أيضًا نقطةً محورية، والتي تكون هي نفسها مركز الدائرة في الحالة الأبسط، ولكنها في العموم قد تكون أي نقطة أخرى داخل الدائرة.

تحدِّد وقفة اللون بالموضع 0 اللون المُستخدَم عند النقطة المحورية، بينما تُحدِّد وقفة اللون بالموضع 1 اللون المُستخدَم على طول الدائرة، وتَستخدِم جميع الرسوم بالصورة التالية نفس نمط التلوين المُتدرِّج، ولكنها تختلف بمكان النقطة المحورية. ستلاحظ وجود علامتين بالرسوم الموجودة بالصف الأول من الصورة، إذ تُمثِّل العلامتان الدائرة والنقطة المحورية. لاحِظ أن اللون المُستخدَم عند النقطة المحورية هو الأحمر، بينما اللون المُستخدَم على طول الدائرة هو الأصفر:

004Radial_Gradient.jpg

يَستقبِل باني الكائن كثيرًا من المعاملات:

radialGradient = new RadialGradient( focalAngle,focalDistance,
                            centerX,centerY,radius,
                            proportional, cycleMethod,
                            stop1, stop2, . . . );

يُخصِّص المعاملان الأول والثاني موضع النقطة المحورية، إذ يشير focalDistance إلى المسافة التي تبعدها النقطة المحورية عن مركز الدائرة، وتُخصَّص على أنها نسبةٌ من نصف القطر، أي لا بُدّ أن تكون أصغر من 1؛ بينما يشير focalAngle إلى الاتجاه الذي تتحرك إليه النقطة المحورية بعيدًا عن المركز.

في الحالة الأبسط من نمط التلوين المتدرج الدائري، تكون المسافة مساويةً للصفر ولا يكون للاتجاه أي معنًى. تُخصِّص المعاملات الثلاثة التالية مركز الدائرة وطول نصف قطرها؛ في حين تعمل المعاملات المتبقية على نحوٍ مشابه للمعاملات المُستخدَمة بنمط التلوين المتدرج الخطي.

التحويلات Transforms

تشير النقطة (0,0) تبعًا لنظام الإحداثيات القياسي الخاص بمكون الحاوية إلى ركنها الأيسر العلوي؛ بينما تشير النقطة (x,y) إلى تلك النقطة التي تبعد مسافة طولها x بكسل من الجانب الأيسر للحاوية ومسافة y بكسل من جانبها العلوي، ومع ذلك ليس هناك أي تقييد بخصوص ضرورة استخدام نظام الاحداثيات ذاك، ففي الحقيقة، يُمكِننا أن نضبُط كائن السياق الرسومي لكي يَستخدِم أنظمة إحداثيات أخرى تعتمد على وحدات طول مختلفة، بل ومحاور إحداثيات مختلفة. والهدف من ذلك هو اختيار نظام الإحداثيات الذي يتناسب أكثر مع ما نرغب برسمه. على سبيل المثال، إذا كنا نرسم مخططات معمارية، يُمكِننا عندها استخدام إحداثيات يُمثِّل طول كل وحدةٍ منها القيمة الفعلية لواحد قدم.

يُطلَق اسم "التحويلات transforms" على التغييرات الحادثة بنظام الإحداثيات. وهناك ثلاثة أنواع بسيطة من التحويلات يمكن توضيحها في الآتي:

  • أولًا، يُعدِّل الانتقال translate موضع نقطة الأصل (0,0).
  • ثانيًا، يُعدِّل التحجيم scale المقياس المُستخدَم أي وحدة المسافة.
  • ثالثًا، يُطبِّق الدوران rotation دورانًا حول نقطة.

تتوفَّر تحويلات أخرى أقل شيوعًا، مثل الإمالة shear التي تُميِل الصورة بعض الشيء. تعرِض الرسمة التوضيحية التالية نفس الصورة بعد إجراء تحويلات مختلفة عليها:

005Transforms.png

لاحظِ أن كل محتويات الصورة بما في ذلك النصوص، قد تأثرت بالتحويلات المُجراة.

يُمكِننا أن نُجرِي تحويلًا أكثر تعقيدًا بدمج مجموعةٍ من تلك التحويلات الثلاثة البسيطة، إذ يمكننا مثلًا، تطبيق دورانٍ متبوعٍ بتحجيم، ثم بانتقال، ثم بدورانٍ مرةً أخرى. عندما نُطبِّق عدة تحويلات بالتتابع سيتراكم تأثيرها، ولهذا يكون من الصعب عادةً فهم تأثير التحويلات المعقدة فهمًا كاملًا.

تُعدّ التحويلات عمومًا موضوعًا ضخمًا يُمكِن تغطيته بدورة عن الرسوم الحاسوبية computer graphics، ولكننا نناقش هنا فقط بعض الحالات البسيطة لكي نحظى بفكرةٍ عما يُمكِن لتلك التحويلات أن تفعله.

يُعدّ التحويل الحالي خاصيةً مُعرَّفةً بكائن السياق الرسومي؛ فهو يُمثِّل جزءًا من الحالة التي يُخزِّنها التابع save()‎ ويستعيدها التابع restore()‎. من المهم جدًا اِستخدَام التابعين save()‎ و restore()‎ عند التعامل مع التحويلات؛ لكي نمنع تأثير التحويلات التي يجريها برنامجٌ فرعيٌ معين على ما يتبعه من استدعاءات لبرامج فرعية أخرى. وبالمثل من بقية الخاصيات الأخرى المُعرَّفة بكائن السياق الرسومي، يمتد تأثير التحويلات على الأشياء المرسومة لاحقًا بعد تطبيق التحويلات على كائن السياق الرسومي.

لنفترض أن g كائن سياق رسومي من النوع GraphicsContext، عندها يُمكِننا أن نُطبِّق انتقالًا على g باستدعاء g.translate(x,y)‎؛ إذ تمثِّل x و y قيمًا من النوع double، ويتلخص تأثيرها حسابيًا في إضافة (x,y) على الإحداثيات بعمليات الرسم التالية. فإذا استخدمنا مثلًا النقطة (0,0) بعد تطبيق هذا الانتقال، فإننا فعليًا نشير إلى النقطة التي إحداثياتها تساوي (x,y) وفقَا لنظام الإحداثيات القياسي، ويَعنِي ذلك أن جميع أزواج الإحداثيات قد تحركت بمقدارٍ معين. ألقِ نظرةً على التعليمتين التاليتين:

g.translate(x,y);
g.strokeLine( 0, 0, 100, 200 );

ترسم التعليمتان السابقتان نفس الخط الذي ترسمه التعليمة التالية:

g.strokeLine( x, y, 100+x, 200+y );

تؤدي النسخة الثانية من الشيفرة نفس عملية الانتقال ولكن يدويًا، وبدلًا من محاولة التفكير بعملية الانتقال باستخدام أنظمة الإحداثيات، قد يكون من الأسهل لنا لو فكرنا بما يَحدُث للأشكال التي ستُرسَم لاحقًا، فمثلًا، بعد استدعاء التابع g.translate(x,y)‎، ستتحرك جميع الكائنات التي نرسمها بمسافة x من الوحدات أفقيًا وبمسافة y من الوحدات رأسيًا.

وفي مثال آخر، قد يُفضِّل البعض أن تُمثِّل النقطة (0,0) منتصف مكون الحاوية بدلًا من ركنها الأيسر العلوي، ويُمكِننا ذلك باستدعاء الأمر التالي قبل رسم أي شيء:

g.translate( canvas.getWidth()/2, canvas.getHeight()/2 );

يُمكِننا أن نُطبِّق تحجيمًا على g باستدعاء التابع g.scale(sx,sy)‎، إذ تشير المعاملات إلى معامل التحجيم بالاتجاهين x و y. وبعد تنفيذ هذا الأمر، ستُضرَّب إحداثيات x بمقدار يُساوِي sx؛ في حين تُضرَّب إحداثيات y بمقدار يُساوِي sy، ويكون تأثير ذلك على الأشياء المرسومة هو بجعلها أكبر أو أصغر. بالتحديد، تؤدي معاملات التحجيم التي تتجاوز قيمتها العدد 1 إلى تكبير حجم الأشكال؛ في حين تؤدي معاملات التحجيم التي قيمتها أقل من 1 إلى تصغير حجمها. وتُستخدَم عادةً نفس قيمة معامل التحجيم للمحورين، ويُطلَق عليه في تلك الحالة اسم "التحجيم المنتظم uniform scaling".

تُعدّ النقطة (0,0) مركز التحجيم، أي النقطة التي لا تتأثر بعملية التحجيم؛ بينما تنتقل النقاط الأخرى باتجاه أو بعكس اتجاه النقطة (0,0)، وإذا لم يكن الشكل موجودًا بالنقطة (0,0)، فلا يقتصر تأثير التحجيم على تغيير حجم الشكل، وإنما نقله أيضًا بعيدًا عن النقطة (0,0) لمعاملات التحجيم الأكبر من 1 وقريبًا من النقطة (0,0) لمعاملات التحجيم الأقل من 1.

يُمكِننا أيضًا أن نَستخدِم معاملات تحجيم سالبة، والتي يَنتُج عنها حدوث انعكاس، إذ تنعكس الأشكال أفقيًا مثلًا حول الخط x=0، وذلك بعد استدعاء التابع g.scale(-1,1)‎.

يُعدّ الدوران هو النوع الثالث من التحويلات البسيطة، إذ يتسبَّب استدعاء التابع g.rotate(r)‎ بدوران جميع الأشكال التي نرسمها لاحقًا بزاوية r حول النقطة (0,0). تُقاس الزوايا بوحدة الدرجات، وتُمثِّل الزوايا الموجبة دورانًا باتجاه عقارب الساعة؛ بينما تُمثِل الزوايا السالبة دورانًا بعكس اتجاه عقارب الساعة، إلا لو كنا قد طبقنا مُسبقًا معامل تحجيم سالب، إذ يؤدي إلى عَكْس الإتجاه.

لا تُعدّ الإمالة shearing عملية تحويل بسيطة، وذلك لأنه من الممكن تنفيذها (مع بعض الصعوبة) عبر متتالية من عمليات الدوران والتحجيم؛ إذ يتمثَّل تأثير الإمالة الأفقية بنقل الخطوط الأفقية إلى اليسار أو إلى اليمين بمقدار يتناسب مع المسافة من المحور الأفقي x، فتنتقل النقطة (x,y) إلى النقطة (x+a*y,y)، لأن a هو مقدار الإمالة ذاك.

لا تتضمّن مكتبة JavaFX تابعًا لتطبيق عملية الإمالة، ولكنها تملُك طريقةً لتطبيق عملية تحويل عشوائي. إذا كان لديك معرفةٌ بالجبر الخطي linear algebra، فلربما تعرف أن المصفوفات تُستخدَم لتمثيل التحويلات، وأنه من الممكن تخصيص أي عملية تحويل بتحديد الأعداد الموجودة بالمصفوفة مباشرةً.

تَستخدِم مكتبة JavaFX كائنات من النوع Affine لتمثيل التحويلات، ويُطبِّق التابع g.transform(t)‎ تحويلًا t من النوع Affine على كائن السياق الرسومي. لن نتطرّق للرياضيات هنا، ولكن تُجرِي الشيفرة التالية عملية إمالة أفقية بمقدار يساوي a:

g.transform( new Affine(1, a, 0, 0, 1, 0) );

قد نحتاج في بعض الأحيان إلى تطبيق عدة تحويلات للحصول على التأثير المطلوب. لنفترض مثلًا أننا نريد أن نعرض كلمة "hello world" بعد إمالتها بزاوية 30 درجة، بحيث تقع نقطتها الأصلية بالنقطة (x,y). في الواقع، لن تتمكَّن الشيفرة التالية من تنفيذ ذلك:

g.rotate(-30);
g.fillText("hello world", x, y);

يكمُن سبب المشكلة في أن عملية الدوران تُطبَّق على كُلٍ من النقطة (x,y) والنص، وبالتالي لا تظِلّ النقطة الأصلية بالموضع (x,y) بعد تطبيق عملية الدوران؛ فكل ما نحتاج إليه هو أن نُنشِئ سلسلةً نصيةً مدارةً بنقطة أصلية واقعة عند (0,0)، ثم يُمكِننا أن ننقلها مسافة (x,y)، وهو ما سيؤدي إلى تحريك النقطة الأصلية من (0,0) إلى (x,y). تُنفِّذ الشيفرة التالية ذلك:

g.translate(x,y);
g.rotate(-30);
g.fillText("hello world", 0, 0);

ما ينبغي ملاحظته هنا هو الترتيب الذي تُطبَّق به عمليات التحويل؛ إذ تُطبَّق عملية الانتقال على جميع الأشياء التي تلي الأمر translate، والتي هي ببساطة بعض الشيفرة المسؤولة عن رسم سلسلة نصية مدارة بنقطة أصلية واقعة عند (0,0). بعد ذلك، تنتقل تلك السلسلة النصية المدارة بمسافة قدرها (x,y). يَعنِي ذلك أن السلسلة النصية تُدار أولًا ثم تُنقَل. يُمكِننا أن نُلخِص ذلك في أن عمليات الانتقال تُطبَّق بترتيبٍ معاكس لترتيب ظهور أوامر الانتقال بالشيفرة.

بإمكان البرنامج التوضيحي TransformDemo.java تطبيق تشكيلةٍ مختلفة من التحويلات على صورة، كما يُمكِّن المُستخدِم من التحكُّم بمقدار كُلٍ من عمليات التحجيم والإمالة الأفقية والدوران والانتقال. قد يساعدك تشغيل البرنامج على فهم التحويلات أكثر، كما يُمكِّنك من فحص الشيفرة المصدرية للبرنامج لترى طريقة تنفيذ تلك العمليات.

تُطبَّق التحويلات ضمن هذا البرنامج وفقًا للترتيب التالي: تحجيم، ثم إمالة، ثم دوران، ثم إنتقال. وإذا فحصت الشيفرة، سترى أن التوابع المسؤولة عن عمليات التحويل مُستدعاة بالترتيب المعاكس: انتقال، ثم دوران، ثم إمالة، ثم تحجيم. هنالك أيضًا عملية انتقال إضافية لتحريك نقطة الأصل إلى مركز الحاوية لكي يصبح مركز عمليات التحجيم والدوران والإمالة هو منتصف الحاوية.

الحاويات المكدسة Stacked

يُعدّ برنامج الرسم البسيط ToolPaint.java المثال الأخير الذي سنتعرض له بهذا القسم. كنا قد تعرضنا لبرامج رسم في جزئيات سابقة من هذه السلسلة، ولكنها كانت مقتصرةً على رسم المنحنيات فقط، ولكن سيُمكِّن البرنامج الجديد المُستخدِم من اختيار أداةٍ للرسم، إضافةً إلى أداة رسم المنحنيات؛ إذ سيتضمَّن البرنامج أدوات لرسم خمسة أنواع من الأشكال موضحة في الآتي:

  • خطوط مستقيمة.
  • مستطيلات.
  • أشكال بيضاوية .
  • مستطيلات ملونة.
  • أشكال بيضاوية ملونة.

عندما يَرسِم المُستخدِم شكلًا بأي من أدوات الرسم الخمسة، فعليه سحَب الفأرة ورسم الشكل من النقطة التي بدأت عندها عملية السحب إلى الموضع الحالي للفأرة. وبكل مرة تتحرك خلالها الفأرة، يُحذَف الشكل السابق ويُرسَم شكلٌ جديد. بالنسبة لأداة رسم الخط مثلًا، يكون التأثير هو رؤية خطٍ مُمتدٍ من نقطة بدء عملية السحب إلى الموضع الحالي لمؤشر الفأرة، ويتحرك هذا الخط مع حركة الفأرة. سيكون من الأفضل لو شغّلت البرنامج وجرَّبت هذا التأثير بنفسك.

تَكْمُن صعوبة برمجة ذلك في أن بعض أجزاء الرسمة تُغطَّى وتُكشَف باستمرار، بينما يُحرِّك المُستخدِم مؤشر الفأرة؛ وعندما يُغطَّى جزءٌ من الرسمة الحالية ثم يُكشَف عنه مرةً أخرى، فلا بُدّ أن تَظلّ الرسمة ظاهرة. ويَعنِي ذلك أن البرنامج لا يستطيع أن يرسم الشكل على نفس الحاوية المُتضمِّنة للرسمة؛ لأن ذلك سيَمحِي ما كان موجودًا بذلك الجزء من الرسمة.

يتلخص الحل بمكتبة JavaFX باستخدام مكوني حاوية واحدًا فوق الآخر؛ بحيث يحتوي مكون الحاوية السفلي على الرسمة الفعلية؛ بينما يُستخدَم مكون الحاوية العلوي لتنفيذ أدوات رسم الأشكال. ينبغي أن تكون الحاوية العلوية شفافة، أي أن تُملأ بلونٍ درجة شفافيته تُساوِي صفر. تُرسَم الأشكال بالحاوية العلوية بينما يسَحب المُستخدِم مؤشر الفأرة بعد اختياره لأداة رسم معينة، وبالتالي لا تتأثر الحاوية السفلية مع إمكانية اختفاء بعض أجزائها خلف الشكل المرسوم بالحاوية العلوية.

في كل مرة يتحرك خلالها مؤشر الفأرة، ستُحذَف مكونات الحاوية العلوية وسيُعاد رسم الشكل الجديد بها؛ وعندما يُحرِر المُستخدِم زر الفأرة بنهاية عملية السحب، ستُحذَف مكونات الحاوية العلوية وسيُرسَم الشكل هذه المرة بالحاوية السفلية ليُصبِح جزءًا من الرسمة الفعلية، وبالتالي تُصبِح الحاوية العلوية شفافةً مجددًا بينما تُصبِح مكونات الحاوية السفلية مرئيةً بالكامل. يُمكِننا حذف محتويات حاوية لتصبح بعدها شفافةً تمامًا باستدعاء التعليمة التالية:

g.clearRect( 0, 0, canvas.getWidth(), canvas.getHeight() );

إذ أن g هو كائن السياق الرسومي من النوع GraphicsContext المقابل للحاوية، وتكون الحاويات شفافةً بالكامل بمجرد إنشائها.

يُمكننا استخدام كائنٍ من النوع StackPane لوضع حاويةٍ فوق حاوية أخرى، إذ تُرتِّب كائنات الصنف StackPane عُقدها nodes الأبناء بعضها فوق بعض بنفس ترتيب إضافتها إليها؛ وعليه، يُمكِننا إنشاء الحاويتين المُستخدمتين بهذا البرنامج على النحو التالي:

canvas = new Canvas(width,height); // حاوية الرسم الرئيسية
canvasGraphics = canvas.getGraphicsContext2D();
canvasGraphics.setFill(backgroundColor);
canvasGraphics.fillRect(0,0,width,height);

overlay = new Canvas(width,height); // الحاوية الشفافة العلوية
overlayGraphics = overlay.getGraphicsContext2D();
overlay.setOnMousePressed( e -> mousePressed(e) );
overlay.setOnMouseDragged( e -> mouseDragged(e) );
overlay.setOnMouseReleased( e -> mouseReleased(e) );

StackPane canvasHolder = new StackPane(canvas,overlay);

ملاحظة: أُضيفت معالجات أحداث الفأرة إلى الحاوية العلوية لا السفلية، لأنها تُغطِي الحاوية السفلية؛ فعندما يَنقُر المُستخدِم على الرسمة الموجودة بالحاوية السفلية، فإنه فعليًا ينقر على الحاوية العلوية.

بالمناسبة، لا تَستخدِم أداة رسم المنحنيات الحاوية العلوية تحديدًا، وإنما تُرسَم المنحنيات مباشرةً بالحاوية السفلية. ونظرًا لأن المُستخدِم لا يستطيع حذف أي جزء من المنحنى بعد رسمه، لا حاجة لوضع نسخةٍ مؤقتةٍ منه بالحاوية العلوية.

العمليات على البكسلات

يتضمَّن البرنامج ToolPaint.java أداتين إضافيتين، هما "الحذف Erase" و "التلطيخ Smudge"، إذ تَعمَل كلتاهما بالحاوية السفلية مباشرةً. تَعمَل أداة الحذف على النحو التالي: بينما يَسحَب المُستخدِم مؤشر الفأرة بعد اختيار تلك الأداة، يُملأ مربعٌ صغير حول موضع المؤشر باللون الأسود لكي يحذف جزء الرسمة الموجود بهذا الموضع. وفي المقابل، تَعمَل أداة التلطيخ على النحو التالي: يؤدي السَحْب بعد اختيار تلك الأداة إلى تلطيخ اللون أسفل الأداة، تمامًا كما لو سحبت إصبعك عبر لون مبلل.

تُبيِّن الصورة التالية لقطة شاشة من البرنامج بعد سَحْب أداة التلطيخ حول مركز مستطيل أحمر:

006Smudge_Rect.png

لا تتضمَّن مكتبة JavaFX برنامجًا فرعيًا مبنيًا مُسبقًا لإجراء تلطيخ على صورة، فهو أمرٌ يتطلّب معالجةً مباشرةً لألوان البكسلات كُلٌ على حدى. تتلخص الفكرة الأساسية لتلك الأداة فيما يلي: يَستخدِم البرنامج ثلاث مصفوفات ثنائية البعد بحجم 9 x‏ 9، واحدةٌ لكل مُكوِّن لون؛ أي واحدةٌ تَحمِل المكون الأحمر؛ وواحدة للمكون الأخضر؛ والأخيرة للمكون الأزرق.

عندما ينقر المُستخدِم على الفأرة أثناء استخدام أداة التلطيخ، تُنسَخ مكونات لون البكسلات الموجودة داخل المربع المحيط بمؤشر الفأرة من الصورة إلى المصفوفات الثلاثة، وعندما يُحرِّك المُستخدِم الفأرة، تمتزج بعضًا من ألوانها بألوان البكسلات الموجودة بالمكان الجديد لمؤشر الفأرة. وبنفس الوقت، يُنسَخ بعضٌ من ألوان الصورة إلى المصفوفات، أي أن المصفوفات تُسقِط بعضًا من الألوان التي تحملها وتلتقط بعض الألوان من المكان الجديد. إذا فكرت بالأمر قليلًا، سترى أن ذلك يشبه تمامًا ما يحدث عندما تُمرِّر إصبعك بدهانٍ حديث.

ينبغي إذًا أن نتمكَّن من قراءة ألوان البكسلات الموجودة بالصورة لكي نُنفِّذ تلك العملية، كما علينا أن نكون قادرين على كتابة ألوان جديدة بتلك البكسلات. في الواقع، من السهل كتابة الألوان بالبكسلات باستخدام الصنف PixelWriter؛ فإذا كان g كائن سياق رسومي من النوع GraphicsContext، وكان هذا الكائن مُرتبطًا بمكون حاوية، عندها يُمكِننا إنشاء كائنٍ من النوع PixelWriter لتلك الحاوية باستدعاء ما يلي:

PixelWriter pixWriter = g.getPixelWriter();

ولكي نتمكَّن من ضبط لون البكسل الموجود بالنقطة (x,y) من الحاوية، يُمكِننا استدعاء التابع التالي:

pixWriter.setColor( x, y, color );

إذ أن color هو كائنٌ من النوع Color، وتمثِّل x و y إحداثيات بكسل، وهم ليسوا عُرضَةً لأي عملية تحويل قد تُطبَّق على كائن السياق الرسومي.

لو كانت مكتبة JavaFX توفِّر طريقةً سهلةً لقراءة ألوان البكسلات من الحاوية، فسنكون قد انتهينا فعلًا، ولكن لسوء الحظ، ليس الأمر بهذه البساطة؛ إذ يبدو أن عمليات الرسم لا تُطبَّق على الحاوية على الفور، وإنما تُخزَّن مجموعةٌ من عمليات الرسم، ثم تُرسَل إلى عتاد جهاز الرسوم دفعةً واحدة، وذلك لرفع الكفاءة. بالتحديد، تُرسَل مجموعة العمليات فقط عندما تُصبِح عملية إعادة رسم الحاوية على الشاشة ضرورية. ويَعنِي ذلك، أننا لو قرأنا لون بكسل من الحاوية، فإننا لا نَضمَن أن تكون القيمة التي نحصل عليها متضمّنةً لكل أوامر الرسم التي طبقناها على الحاوية. وبناءً على ذلك، إذا أردنا أن نقرأ ألوان البكسلات، فسنضطّر لفعل شيءٍ ما لضمان اكتمال جميع عمليات الرسم، مثل أخذ لقطة شاشة للحاوية.

يُمكِننا أن نأخذ لقطة شاشة لأي عقدة بمبيان المشهد، وتعيد تلك العملية قيمةً من النوع WritableImage تحتوي على صورة للعقدة بعد تطبيق جميع العمليات قيد الانتظار. تلتقط التعليمة التالية صورةً لعقدة بأكملها:

WritableImage nodePic = node.snapshot(null,null);

يحتوي كائن الصنف WritableImage على صورةٍ للعقدة كما هي ظاهرةٌ على الشاشة، ويُمكِننا أن نَستخدِمه مثل أي كائن صورة عادي ينتمي إلى الصنف Image. يَستقبِل التابع من التعليمة السابقة معاملات من النوع SnapshotParameter و WriteableImage؛ فإذا مررنا صورةً غير فارغة قابلة للكتابة للمعامل الثاني، فسيَستخدِمها التابع مثل صورة طالما كانت كبيرةً كفاية لتَحمِل صورة العقدة، وربما يكون ذلك أكثر كفاءةً من إنشاء صورةٍ جديدة قابلة للكتابة. بالنسبة للمعامل الأول، يُمكِن اِستخدَامه للتحكُّم بالصورة الناتجة على نحوٍ أكبر، إذ يمكنه تحديدًا الطلب بأن تكون لقطة الشاشة مقتصرةً على مستطيلٍ معين من العقدة فقط.

والآن، لكي نُنفِّذ أداة التلطيخ، ينبغي أن نقرأ البكسلات من مستطيلٍ صغيرٍ بالحاوية، إذ تبلغ مساحة ذلك المستطيل 9*9؛ ولإجراء ذلك بكفاءة، سنُمرِّر كائنًا من النوع SnapshotParameter لكي يُخصِّص رغبتنا بلقطة شاشة لذلك المستطيل لا الحاوية بالكامل، وبذلك نكون قد حصلنا على ذلك المستطيل ضمن كائن صورة من النوع WritableImage. وبعد ذلك، سنَستخدِم كائنًا من النوع PixelReader لقراءة ألوان البكسلات من الكائن الذي حصلنا عليه. في الواقع، يتضمَّن تنفيذ ذلك كثيرًا من التفاصيل، ولذلك سنعرض فقط طريقة التنفيذ.

يُنشِئ البرنامج كائنًا واحدًا من كل نوعٍ من الأنواع التالية WritableImage و PixelReader و SnapshotParameter لاستخدامها مع جميع لقطات الشاشة، وقد تقع بعض البكسلات خارج الحاوية لبعض لقطات الشاشة، وهو ما يُعقدّ الأمور قليلًا. ألقِ نظرةً على الشيفرة التالية:

pixels = new WritableImage(9,9);        // a 9-by-9 writable image
pixelReader = pixels.getPixelReader();  // a PixelReader for the writable image
snapshotParams = new SnapshotParameters();

عندما ينقر المُستخدِم على زر الفأرة، ينبغي أن تُؤخذ لقطة شاشة مربعة لجزء الحاوية المحيط بمكان مؤشر الفأرة الحالي (startX,startY)، ثم تُنسخ بيانات الألوان من تلك اللقطة إلى مصفوفات مكونات الألوان smudgeRed و smudgeGreen و smudgeBlue. ألقِ نظرةً على الشيفرة التالية:

snapshotParams.setViewport( new Rectangle2D(startX - 4, startY - 4, 9, 9) );
    // 1
canvas.snapshot(snapshotParams, pixels);
int h = (int)canvas.getHeight();
int w = (int)canvas.getWidth();
for (int j = 0; j < 9; j++) {  // صفٌ في لقطة الشاشة
    int r = startY + j - 4;  // الصف المقابل بالحاوية
    for (int i = 0; i < 9; i++) {  // عمودٌ في لقطة الشاشة
        int c = startX + i - 4;  // العمود المقابل بالحاوية
        if (r < 0 || r >= h || c < 0 || c >= w) {
                // 2
            smudgeRed[j][i] = -1;
        }
        else {
            Color color = pixelReader.getColor(i, j);
                // pixelReader gets color from the snapshot
            smudgeRed[j][i] = color.getRed();
            smudgeGreen[j][i] = color.getGreen();
            smudgeBlue[j][i] = color.getBlue();
        }
    }
}

حيث أن:

  • [1] يُمثِّل viewport المستطيل الموجود بالحاوية والذي سيُضمَّن بلقطة الشاشة.
  • [2] تعني أن النقطة (c,r) تقع خارج الحاوية، كما تشير قيمة -1 بالمصفوفة smudgeRed إلى أن البكسل كان خارج الحاوية.

والآن، علينا مزج اللون الموجود بمصفوفات مكونات الألوان بمربع البكسلات المحيط بالنقطة (x,y)؛ ولكي نُنفِّذ ذلك، سنأخذ لقطة شاشة مربعة جديدة لمربع البكسلات المحيط بالنقطة (x,y)؛ وبمجرد حصولنا على تلك اللقطة، يُمكِننا إجراء الحسابات الضرورية لعملية المزج، ونكتب بعدها اللون الجديد الناتج إلى الحاوية باستخدام كائن الصنف PixelWriter المسؤول عن الكتابة بالحاوية. ألقِ نظرةً على الشيفرة التالية:

snapshotParams.setViewport( new Rectangle2D(x - 4, y - 4, 9, 9) );
canvas.snapshot(snapshotParams, pixels);
for (int j = 0; j < 9; j++) { // صف بلقطة الشاشة
    int c = x - 4 + j;  // الصف المقابل بالحاوية
    for (int i = 0; i < 9; i++) {  // عمود بلقطة الشاشة
        int r = y - 4 + i;  // العمود المقابل بالحاوية
        if ( r >= 0 && r < h && c >= 0 && c < w && smudgeRed[i][j] != -1) {

         // اِسترجِع لون البكسل الحالي من لقطة الشاشة
           Color oldColor = pixelReader.getColor(j,i); 

              // 1
           double newRed = (oldColor.getRed()*0.8 + smudgeRed[i][j]*0.2);
           double newGreen = (oldColor.getGreen()*0.8 + smudgeGreen[i][j]*0.2);
           double newBlue = (oldColor.getBlue()*0.8 + smudgeBlue[i][j]*0.2);

         // اكتب لون البكسل الجديد إلى الحاوية
           pixelWriter.setColor( c, r, Color.color(newRed,newGreen,newBlue) );

              // امزج جزء من اللون الموجود بالحاوية إلى المصفوفات
           smudgeRed[i][j] = oldColor.getRed()*0.2 + smudgeRed[i][j]*0.8;
           smudgeGreen[i][j] = oldColor.getGreen()*0.2 + smudgeGreen[i][j]*0.8;
           smudgeBlue[i][j] = oldColor.getBlue()*0.2 + smudgeBlue[i][j]*0.8;
        }
    }
}

إذ تعني [1]: احصل على لون جديد للبكسل عن طريق دمج اللون الحالي مع مكونات اللون المُخزَّنة بالمصفوفات.

في الواقع، هذه عمليةٌ معقدةٌ نوعًا ما، ولكنها أوضحت طريقة التعامل مع البكسلات إلى حدٍ ما. عليك أن تتذكر أنه من الممكن كتابة ألوان البكسلات إلى الحاوية باستخدام كائنٍ ينتمي إلى الصنف PixelWriter؛ ولكن ينبغي لقراءة البكسلات منها، أن نأخذ لقطة شاشة للحاوية، ثم نَستخدِم كائنًا من الصنف PixelReader لقراءة ألوان البكسلات من كائن النوع WritableImage الذي يحتوي على لقطة الشاشة.

عمليات الدخل والخرج للصور

يحتوي البرنامج التوضيحي ToolPaint.java على قائمة "File" تحتوي على أمرٍ لتحميل صورةٍ من ملفٍ إلى حاوية؛ وأمرٍ آخر لحفظ صورة من حاوية إلى ملف.

بالنسبة لأمر تحميل الصورة، سنحتاج أولًا إلى تحميل الصورة إلى كائنٍ من النوع Image. كنا قد اطلعنا بمقال التعرف على بعض أصناف مكتبة جافا إف إكس JavaFX البسيطة على طريقة تحميل صورةٍ من ملف مورد، وكيفية رسمها ضمن حاوية. وبنفس الطريقة تقريبًا، يُمكِن تحميل صورة من ملف على النحو التالي:

Image imageFromFile = new Image( fileURL );

يُمثِّل المعامل سلسلةً نصيةً تُخصِّص موقع الملف بهيئة محدّد موارد موّحد URL، وهو ببساطة مسار الملف مسبوقٌ بكلمة "file:‎"؛ فإذا كان imageFile كائنًأ من النوع File يحتوي على مسار الملف، فيُمكِننا ببساطة كتابة ما يلي:

Image imageFromFile = new Image( "file:" + imageFile );

نَستخدِم عادةً كائن نافذة اختيار ملف من النوع FileChooser لنسمَح للمُستخدِم باختيار ملف -ألقِ نظرةً على مقال مدخل إلى التعامل مع الملفات في جافا-، وعندها سيكون imageFile هو الملف المختار الذي تُعيده تلك النافذة.

قد يختار المُستخدِم ملفًا لا يُمكِن قراءته، أو لا يحتوي على صورة، وهنا لا يُبلِّغ باني كائن الصنف Image عن استثناءٍ في تلك الحالة، وإنما يَضبُط متغيرًا مُعرَّفًا بالكائن لكي يُشير إلى وجود خطأ؛ لذلك علينا فحص ذلك المتغير باستخدام الدالة imageFromFile.isError()‎ التي تعيد قيمةً من النوع boolean، وفي حالة وجود خطأ فعلًا، يُمكِننا أن نَستعيد الاستثناء المُتسبِّب بالخطأ باستدعاء الدالة imageFromFile.getException()‎.

بمجرد حصولنا على الصورة وتأكُّدنا من عدم وجود خطأ، يُمكِننا أن نرسمها بالحاوية باستخدام كائن السياق الرسومي الخاص بالحاوية. يَضبُط الأمر التالي حجم الصورة، بحيث تملأ الحاوية كاملةً:

g.drawImage( imageFromFile, 0, 0, canvas.getWidth(), canvas.getHeight() );

سنضع جميع ما سبق ضمن تابعٍ، اسمه doOpenImage()‎، وسيَستدعيه البرنامج ToolPaint لتحميل الصورة المُخزَّنة بالملف الذي اختاره المُستخدِم. ألقِ نظرةً على تعريف التابع:

private void doOpenImage() {
    FileChooser fileDialog = new FileChooser(); 
    fileDialog.setInitialFileName("");
    fileDialog.setInitialDirectory(
                           new File( System.getProperty("user.home") ) );
    fileDialog.setTitle("Select Image File to Load");
    File selectedFile = fileDialog.showOpenDialog(window);
    if ( selectedFile == null )
        return;  // لم يختر المُستخدِم أي ملف
    Image image = new Image("file:" + selectedFile);
    if (image.isError()) {
        Alert errorAlert = new Alert(Alert.AlertType.ERROR,
                "Sorry, an error occurred while\ntrying to load the file:\n"
                     + image.getException().getMessage());
        errorAlert.showAndWait();
        return;
    }
    canvasGraphics.drawImage(image,0,0,canvas.getWidth(),canvas.getHeight());
}

والآن، لكي نستخرِج الصورة من حاويةٍ إلى ملف، ينبغي أن نحصل أولًا على الصورة من الحاوية، وذلك بأخذ لقطة شاشة للحاوية بالكامل، وهو ما سنفعله كما يلي:

Image image = canvas.snapshot(null,null);

لسوء الحظ، لا تتضمَّن مكتبة JavaFX -بإصدارها الحالي على الأقل- خاصية حفظ الصور إلى ملفات؛ ولهذا سنعتمد على الصنف BufferedImage من حزمة java.awt.image بأداة تطوير واجهات المُستخدِمة الرسومية AWT القديمة، إذ يُمثِّل ذلك الصنف صورةً مُخزّنةً بذاكرة الحاسوب، مثل الصنف Image بمكتبة JavaFX. في الواقع، يُمكِننا بسهولة تحويل كائن من النوع Image إلى النوع BufferedImage باستخدام تابعٍ ساكن static مُعرَّف بالصنف SwingFXUtils من حزمة javafx.embed.swing على النحو التالي:

BufferedImage bufferedImage = SwingFXUtils.fromFXImage(canvasImage,null);

يُمكِننا أن نُمرِّر كائنًا من النوع BufferedImage مثل معاملٍ ثانٍ للتابع ليَحمِل الصورة، والذي من الممكن أن يأخذ القيمة null.

بمجرد حصولنا على كائن الصنف BufferedImage، يُمكِننا استخدام التابع الساكن التالي المُعرَّف بالصنف ImageIO من حزمة javax.imageio لكتابة الصورة إلى ملف:

ImageIO.write( bufferedImage, format, file );

يُمثِّل المعامل الثاني للتابع السابق سلسلةً نصيةً من النوع String، إذ تخصِّص تلك السلسلة صيغة الملف الذي ستُحفَظ إليه الصورة. يُمكِن حفظ الصور عمومًا بعدة صيغ بما في ذلك "PNG" و "JPEG" و "GIF"، ويستخدم البرنامج ToolPaint صيغة "PNG" دائمًا.

يُمثِل المعامل الثالث كائنًا من النوع File، ويُخصِّص بيانات الملف المطلوب حفظه، إذ يُبلِّغ التابع ImageIO.write()‎ عن استثناءٍ، إذا لم يتمكَّن من حفظ الملف؛ وإذا لم يتعرف التابع على الصيغة، فإنه يَفشَل أيضًا، ولكنه لا يُبلِّغ عن استثناء.

يُمكِننا الآن أن نُضمِّن كل شيء معًا داخل التابع doSaveImage()‎ بالبرنامج ToolPaint. ألقِ نظرةً على تعريف التابع:

private void doSaveImage() {
    FileChooser fileDialog = new FileChooser(); 
    fileDialog.setInitialFileName("imagefile.png");
    fileDialog.setInitialDirectory(
                     new File( System.getProperty("user.home") ) );
    fileDialog.setTitle("Select File to Save. Name MUST end with .png!");
    File selectedFile = fileDialog.showSaveDialog(window);
    if ( selectedFile == null )
        return;  // لم يختر المُستخدِم أي ملف
    try {
        Image canvasImage = canvas.snapshot(null,null);
        BufferedImage image = SwingFXUtils.fromFXImage(canvasImage,null);
        String filename = selectedFile.getName().toLowerCase();
        if ( ! filename.endsWith(".png")) {
            throw new Exception("The file name must end with \".png\".");
        }
        boolean hasFormat = ImageIO.write(image,"PNG",selectedFile);
        if ( ! hasFormat ) { // لا ينبغي أن يحدث ذلك نهائيًا
            throw new Exception( "PNG format not available.");
        }
    }
    catch (Exception e) {
        Alert errorAlert = new Alert(Alert.AlertType.ERROR,
               "Sorry, an error occurred while\ntrying to save the image:\n"
                     + e.getMessage());
        errorAlert.showAndWait();
    }    
}

ترجمة -بتصرّف- للقسم Section 2: Fancier Graphics من فصل Chapter 13: GUI Programming Continued من كتاب Introduction to Programming Using Java.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...