forked from 1b-agent/opencadstudio-upstream
PDF underlays: attach prompts and definitions, display adjustments and clipping, Properties, snapping, PDFCLIP and PDFIMPORT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc9d5edbff
commit
0c50013ca7
50 files changed
+2770
-325
No files matched your search
Generated
+2
-1
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"image",
|
||||
"inventory",
|
||||
"js-sys",
|
||||
"kurbo 0.13.1",
|
||||
"libloading",
|
||||
"log",
|
||||
"lyon_tessellation",
|
||||
@@ -87,7 +88,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.5.5"
|
||||
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=dd1d7bf#dd1d7bfe03f4e733d351c0396e2cf82ae9df957d"
|
||||
source = "git+https://github.com/ramox81/cadcodec.git?rev=55a8987#55a8987aca3432a4b0a3ffde708d32e4ffaca644"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
|
||||
+5
-1
@@ -33,13 +33,14 @@ rfd = "0.17"
|
||||
clap = { version = "4", features = ["derive", "string"] }
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "55a8987", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9", features = ["acis", "offset"] }
|
||||
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9" }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
hayro = "0.7.1"
|
||||
kurbo = "0.13"
|
||||
# Rasterises assets/logo.svg into the window icon at startup, so the taskbar
|
||||
# icon comes from the same file every other build target does. Already in the
|
||||
# tree behind iced's svg feature; named here only to use it directly.
|
||||
@@ -80,6 +81,9 @@ objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "
|
||||
block2 = "0.5"
|
||||
objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] }
|
||||
|
||||
[patch."https://github.com/HakanSeven12/cadcodec.git"]
|
||||
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "55a8987" }
|
||||
|
||||
[patch.crates-io]
|
||||
iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
|
||||
@@ -3510,6 +3510,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE حدد نقطة الإدراج:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
سحب
|
||||
النقاط
|
||||
@@ -4258,6 +4259,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = حجم رأس السهم
|
||||
.attachment = إرفاق
|
||||
.chord = وتر
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Посочи точка на вмъкване:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Облаци
|
||||
от точки
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Размер на върха на стрелката
|
||||
.attachment = Прикачване
|
||||
.chord = Хорда
|
||||
|
||||
@@ -3495,6 +3495,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Zadejte bod vložení:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Bod
|
||||
Mraky
|
||||
@@ -4243,6 +4244,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Velikost hrotu šipky
|
||||
.attachment = Připojení
|
||||
.chord = Tětiva
|
||||
|
||||
@@ -3498,6 +3498,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Einführstelle angeben:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Punkt-
|
||||
wolken
|
||||
@@ -4244,6 +4245,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Pfeilspitzengröße
|
||||
.attachment = Zuordnung
|
||||
.chord = Sehne
|
||||
|
||||
@@ -3498,6 +3498,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Ορίστε σημείο εισαγωγής:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.revolve-creation-mode-solid-surface = REVOLVE Τρόπος δημιουργίας [Solid/Surface]
|
||||
.revolve-enter-angle-expression = REVOLVE Δώστε έκφραση γωνίας:
|
||||
.revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Επιλέξτε γραμμή, ημιευθεία ή βοηθητική ευθεία ως άξονα:
|
||||
@@ -4246,6 +4247,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.angle-of-revolution = Γωνία περιστροφής
|
||||
.axis-direction-x = Διεύθυνση άξονα X
|
||||
.axis-direction-y = Διεύθυνση άξονα Y
|
||||
|
||||
@@ -3499,6 +3499,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Specify insertion point:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Point
|
||||
Clouds
|
||||
@@ -4247,6 +4248,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS specify point in positive XY plane:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.count-objects-selected = __ocs_arg_count__ objects selected
|
||||
.scope-visual-style = __ocs_arg_scope__ Visual Style
|
||||
.value-3d-face = 3D Face
|
||||
|
||||
@@ -3498,6 +3498,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Especifique el punto de inserción:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Nubes de
|
||||
puntos
|
||||
@@ -4246,6 +4247,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Tamaño de punta de flecha
|
||||
.attachment = Enlace
|
||||
.chord = Cuerda
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Määritä lisäyskohta:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Point
|
||||
Pilviä
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Nuolenkärjen koko
|
||||
.attachment = Liitos
|
||||
.chord = Jänne
|
||||
|
||||
@@ -3498,6 +3498,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Spécifier le point d'insertion:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Nuages de
|
||||
points
|
||||
@@ -4246,6 +4247,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Taille de la pointe de flèche
|
||||
.attachment = Attachement
|
||||
.chord = Corde
|
||||
|
||||
@@ -3486,6 +3486,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE निर्दिष्ट सम्मिलन बिंदु:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
बिंदु
|
||||
बादल
|
||||
@@ -4234,6 +4235,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = तीर शीर्ष का आकार
|
||||
.attachment = संलग्नक
|
||||
.chord = जीवा
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Beszúrási pont megadása:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Pont
|
||||
Felhők
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Nyílhegy mérete
|
||||
.attachment = Csatolás
|
||||
.chord = Húr
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Specificare il punto di inserimento:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Punto
|
||||
Nuvole
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Dimensione punta freccia
|
||||
.attachment = Collegamento
|
||||
.chord = Corda
|
||||
|
||||
@@ -3488,6 +3488,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE 挿入点を指定:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds = 点群
|
||||
.attsync-block-name-to-sync = ATTSYNC 同期するブロック名:
|
||||
.attsync-no-block-named-arg = ATTSYNC: "__ocs_fmt_0__" というブロックはありません。
|
||||
@@ -4234,6 +4235,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = 矢印の先端サイズ
|
||||
.attachment = アタッチ
|
||||
.chord = 弦
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE 삽입점 지정:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
포인트
|
||||
구름
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = 화살촉 크기
|
||||
.attachment = 부착
|
||||
.chord = 현
|
||||
|
||||
@@ -3496,6 +3496,7 @@ annotate =
|
||||
.dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selecteer een boog, cirkel of polylijnboog:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Punten-
|
||||
wolken
|
||||
@@ -4244,6 +4245,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Pijlpuntgrootte
|
||||
.attachment = Koppeling
|
||||
.chord = Koorde
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Określ punkt wstawienia:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Punkt
|
||||
Chmury
|
||||
@@ -4245,6 +4246,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Rozmiar grotu strzałki
|
||||
.attachment = Dołączenie
|
||||
.chord = Cięciwa
|
||||
|
||||
@@ -3498,6 +3498,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Especifique o ponto de inserção:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Nuvens de
|
||||
pontos
|
||||
@@ -4246,6 +4247,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Tamanho da ponta da seta
|
||||
.attachment = Anexação
|
||||
.chord = Corda
|
||||
|
||||
@@ -3499,6 +3499,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Укажите точку вставки:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds =
|
||||
Облака
|
||||
точек
|
||||
@@ -4247,6 +4248,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = Размер стрелки
|
||||
.attachment = Прикрепление
|
||||
.chord = Хорда
|
||||
|
||||
@@ -3472,6 +3472,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE Ekleme noktasını belirtin:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = PDF Dosyası Seç
|
||||
.point-clouds =
|
||||
Nokta
|
||||
Bulutları
|
||||
@@ -4186,6 +4187,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Altlığı
|
||||
.dwf-underlay = DWF Altlığı
|
||||
.dgn-underlay = DGN Altlığı
|
||||
.page-number = Sayfa numarası
|
||||
.layer-display-overrides = Katman görüntüleme geçersiz kılmaları
|
||||
.extended-data = Genişletilmiş Veri
|
||||
.count-objects-selected = __ocs_arg_count__ nesne seçildi
|
||||
.scope-visual-style = __ocs_arg_scope__ Görsel Stil
|
||||
|
||||
@@ -3485,6 +3485,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE 指定插入点:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds = 点云
|
||||
.attsync-block-name-to-sync = ATTSYNC 要同步的块名 :
|
||||
.attsync-no-block-named-arg = ATTSYNC:没有块名“__ocs_fmt_0__”。
|
||||
@@ -4229,6 +4230,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = 箭头大小
|
||||
.attachment = 附着
|
||||
.chord = 弦
|
||||
|
||||
@@ -3497,6 +3497,7 @@ annotate =
|
||||
.tolerance-specify-insertion-point = TOLERANCE 指定插入點:
|
||||
|
||||
insert =
|
||||
.select-pdf-file = Select PDF File
|
||||
.point-clouds = 點雲
|
||||
.attsync-block-name-to-sync = ATTSYNC 要同步的圖塊名 :
|
||||
.attsync-no-block-named-arg = ATTSYNC:沒有圖塊名“__ocs_fmt_0__”。
|
||||
@@ -4241,6 +4242,11 @@ view =
|
||||
.ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }:
|
||||
|
||||
properties =
|
||||
.pdf-underlay = PDF Underlay
|
||||
.dwf-underlay = DWF Underlay
|
||||
.dgn-underlay = DGN Underlay
|
||||
.page-number = Page number
|
||||
.layer-display-overrides = Layer display overrides
|
||||
.arrowhead-size = 箭頭大小
|
||||
.attachment = 附著
|
||||
.chord = 弦
|
||||
|
||||
@@ -818,7 +818,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
if let Some(bounds) = bounds {
|
||||
let (min, max) = crate::scene::convert::tess::entity_bounds(e);
|
||||
let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, e);
|
||||
if max[0] < bounds[0]
|
||||
|| max[1] < bounds[1]
|
||||
|| min[0] > bounds[2]
|
||||
|
||||
@@ -3190,8 +3190,24 @@ impl OpenCADStudio {
|
||||
// (before `entity` is moved into commit_entity).
|
||||
self.update_cont_anchor(&entity);
|
||||
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
||||
let delta_safe = self.delta_add_safe(i, &entity);
|
||||
// A PDF attach creates its definition (and the definitions
|
||||
// dictionary) with the underlay, in the same undo step.
|
||||
let pdf_source = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.and_then(|cmd| cmd.pdf_attach_source());
|
||||
let delta_safe = pdf_source.is_none() && self.delta_add_safe(i, &entity);
|
||||
let pending = self.begin_undo(i, label, 1, delta_safe);
|
||||
if let Some((path, page)) = pdf_source {
|
||||
let definition = crate::modules::insert::pdf_attach::ensure_pdf_definition(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&path,
|
||||
&page,
|
||||
);
|
||||
if let acadrust::EntityType::Underlay(underlay) = &mut entity {
|
||||
underlay.definition_handle = definition;
|
||||
}
|
||||
}
|
||||
let is_associative_dimension = matches!(
|
||||
entity,
|
||||
acadrust::EntityType::Dimension(
|
||||
@@ -3931,6 +3947,15 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.refresh_properties();
|
||||
}
|
||||
CmdResult::PdfImport(request) => {
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.run_pdf_import(
|
||||
i,
|
||||
crate::app::commands::pdf_import::PdfImportSource::Underlay(request),
|
||||
);
|
||||
}
|
||||
CmdResult::UpdateEntityAndFinish { handle, entity } => {
|
||||
if self.reject_locked_edit(i, handle) {
|
||||
self.tabs[i].active_cmd = None;
|
||||
|
||||
@@ -660,8 +660,48 @@ impl OpenCADStudio {
|
||||
self.open_attedit_dialog();
|
||||
}
|
||||
|
||||
"PDFATTACH" => {
|
||||
return Some(Task::done(Message::PdfAttachPick));
|
||||
"PDFIMPORT" => {
|
||||
use crate::command::CadCommand;
|
||||
let command = crate::modules::insert::pdf_import::PdfImportCommand::new();
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
"_PDFIMPORTFILE" => {
|
||||
return Some(Task::done(Message::PdfImportPick));
|
||||
}
|
||||
"PDFCLIP" | "CLIP" => {
|
||||
use crate::command::CadCommand;
|
||||
let command = crate::modules::insert::pdf_clip::PdfClipCommand::new(cmd == "CLIP");
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
// PDFATTACH picks the file; -PDFATTACH asks for it on the command
|
||||
// line. A path after either names the file at once.
|
||||
cmd if matches!(
|
||||
cmd.split_whitespace().next().map(|v| v.to_ascii_uppercase()).as_deref(),
|
||||
Some("PDFATTACH") | Some("-PDFATTACH")
|
||||
) =>
|
||||
{
|
||||
use crate::command::CadCommand;
|
||||
let (verb, rest) = cmd
|
||||
.split_once(char::is_whitespace)
|
||||
.map(|(verb, rest)| (verb.to_ascii_uppercase(), rest.trim()))
|
||||
.unwrap_or_else(|| (cmd.to_ascii_uppercase(), ""));
|
||||
if verb == "PDFATTACH" && rest.is_empty() {
|
||||
return Some(Task::done(Message::PdfAttachPick));
|
||||
}
|
||||
let insunits = self.tabs[i].scene.document.header.insertion_units;
|
||||
let mut command =
|
||||
crate::modules::insert::pdf_attach::PdfAttachCommand::new(insunits);
|
||||
if !rest.is_empty() {
|
||||
if let Some(crate::command::CmdResult::ReportError(message)) =
|
||||
command.on_text_input(rest)
|
||||
{
|
||||
self.command_line.push_error(&message);
|
||||
}
|
||||
}
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
"XATTACH" => {
|
||||
// Launch the file picker; XAttachPickResult will start the command.
|
||||
|
||||
@@ -1861,8 +1861,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS"
|
||||
| "UOSNAP" => {
|
||||
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" => {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("{cmd}: not yet implemented.").as_ref());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ mod fileops;
|
||||
mod inquiry;
|
||||
mod layerprops;
|
||||
mod layers;
|
||||
pub(crate) mod pdf_import;
|
||||
mod plotvars;
|
||||
mod styleprops;
|
||||
mod view;
|
||||
@@ -606,6 +607,8 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"FRAME",
|
||||
"IMAGEFRAME",
|
||||
"PDFFRAME",
|
||||
"PDFOSNAP",
|
||||
"UOSNAP",
|
||||
"POINTCLOUDCLIPFRAME",
|
||||
"XCLIPFRAME",
|
||||
"WIPEOUTFRAME",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! PDFIMPORT: adding the converted PDF content to the drawing as one undo
|
||||
//! step, with the layers and text styles it uses, and what then happens to
|
||||
//! the underlay (Keep / Detach / Unload).
|
||||
|
||||
use super::*;
|
||||
use crate::modules::insert::pdf_import::{self, ImportArea, PdfImportRequest, UnderlayMode};
|
||||
|
||||
/// What an import reads: a placed underlay, or a file imported at the
|
||||
/// origin at full size.
|
||||
pub(crate) enum PdfImportSource {
|
||||
Underlay(PdfImportRequest),
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(crate) fn run_pdf_import(&mut self, i: usize, source: PdfImportSource) {
|
||||
use acadrust::objects::ObjectType;
|
||||
let document = &self.tabs[i].scene.document;
|
||||
let (underlay, path, page, area, mode, handle) = match &source {
|
||||
PdfImportSource::Underlay(request) => {
|
||||
let Some(acadrust::EntityType::Underlay(underlay)) =
|
||||
document.get_entity(request.underlay)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(def) = crate::entities::underlay::definition(underlay, document) else {
|
||||
return;
|
||||
};
|
||||
if self.tabs[i]
|
||||
.scene
|
||||
.unloaded_underlay_definitions
|
||||
.contains(&underlay.definition_handle)
|
||||
{
|
||||
self.command_line
|
||||
.push_error("Cannot bind a PDF underlay that is unloaded.");
|
||||
return;
|
||||
}
|
||||
(
|
||||
underlay.clone(),
|
||||
def.file_path.clone(),
|
||||
crate::entities::underlay::page_of(def).to_string(),
|
||||
request.area.clone(),
|
||||
Some(request.mode),
|
||||
Some(request.underlay),
|
||||
)
|
||||
}
|
||||
PdfImportSource::File(path) => (
|
||||
acadrust::entities::Underlay::pdf(),
|
||||
path.clone(),
|
||||
"1".to_string(),
|
||||
ImportArea::All,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let shown = crate::entities::underlay::display_path(&path);
|
||||
match &source {
|
||||
PdfImportSource::Underlay(_) => self
|
||||
.command_line
|
||||
.push_output(&format!("Binding PDF file {shown}, page {page} ...")),
|
||||
PdfImportSource::File(_) => self
|
||||
.command_line
|
||||
.push_output(&format!("Importing page {page} of PDF file: {shown}...")),
|
||||
}
|
||||
let Some(vectors) = crate::scene::model::pdf_vector::page_vectors(&path, &page) else {
|
||||
self.command_line.push_error(&format!("{shown} not found."));
|
||||
return;
|
||||
};
|
||||
let result = pdf_import::convert(&vectors, &underlay, &area);
|
||||
|
||||
self.push_undo_snapshot(i, "PDFIMPORT");
|
||||
let scene = &mut self.tabs[i].scene;
|
||||
for (name, color) in &result.layers {
|
||||
if !scene.document.layers.contains(name) {
|
||||
let mut layer = acadrust::tables::Layer::new(name.as_str());
|
||||
layer.handle = scene.document.allocate_handle();
|
||||
layer.color = *color;
|
||||
let _ = scene.document.layers.add(layer);
|
||||
}
|
||||
}
|
||||
for (name, font) in &result.text_styles {
|
||||
if !scene.document.text_styles.contains(name) {
|
||||
let mut style = acadrust::tables::TextStyle::new(name.as_str());
|
||||
style.handle = scene.document.allocate_handle();
|
||||
style.font_file = font.clone();
|
||||
let _ = scene.document.text_styles.add(style);
|
||||
}
|
||||
}
|
||||
for entity in result.entities {
|
||||
scene.add_entity(entity);
|
||||
}
|
||||
if let (Some(mode), Some(handle)) = (mode, handle) {
|
||||
match mode {
|
||||
UnderlayMode::Keep => {}
|
||||
UnderlayMode::Unload => {
|
||||
scene
|
||||
.unloaded_underlay_definitions
|
||||
.insert(underlay.definition_handle);
|
||||
scene.reseed_underlays();
|
||||
}
|
||||
UnderlayMode::Detach => {
|
||||
scene.erase_entities(&[handle]);
|
||||
// Drop the definition once nothing references it.
|
||||
let definition = underlay.definition_handle;
|
||||
let referenced = scene.document.entities().any(|entity| {
|
||||
matches!(entity, acadrust::EntityType::Underlay(other)
|
||||
if other.definition_handle == definition)
|
||||
});
|
||||
if !referenced {
|
||||
scene.document.objects.remove(&definition);
|
||||
for object in scene.document.objects.values_mut() {
|
||||
if let ObjectType::Dictionary(dictionary) = object {
|
||||
dictionary.entries.retain(|(_, target)| *target != definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.refresh_properties();
|
||||
}
|
||||
}
|
||||
@@ -1015,6 +1015,8 @@ impl OpenCADStudio {
|
||||
| "FRAME"
|
||||
| "IMAGEFRAME"
|
||||
| "PDFFRAME"
|
||||
| "PDFOSNAP"
|
||||
| "UOSNAP"
|
||||
| "POINTCLOUDCLIPFRAME"
|
||||
| "XCLIPFRAME"
|
||||
| "WIPEOUTFRAME"
|
||||
@@ -1106,6 +1108,26 @@ impl OpenCADStudio {
|
||||
}
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
// Snapping to the geometry inside underlays (one switch for
|
||||
// PDF underlays and underlays in general).
|
||||
if matches!(name.as_str(), "PDFOSNAP" | "UOSNAP") {
|
||||
let current = i16::from(crate::scene::model::pdf_vector::pdf_osnap());
|
||||
if let Some(value) = &value {
|
||||
match value.parse::<i16>().ok().filter(|value| (0..=1).contains(value)) {
|
||||
Some(mode) => {
|
||||
if current != mode {
|
||||
crate::scene::model::pdf_vector::set_pdf_osnap(mode == 1);
|
||||
self.tabs[i].scene.reseed_underlays();
|
||||
}
|
||||
}
|
||||
None => self.command_line.push_error("Requires 0 or 1 only"),
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
|
||||
self.pending_setvar = Some(name.clone());
|
||||
}
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") {
|
||||
let current = if name == "SHOWHIST" {
|
||||
self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2)
|
||||
|
||||
@@ -1162,7 +1162,7 @@ impl OpenCADStudio {
|
||||
let Some(entity) = tab.scene.document.get_entity(handle) else {
|
||||
continue;
|
||||
};
|
||||
let (min, max) = crate::scene::convert::tess::entity_bounds(entity);
|
||||
let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity);
|
||||
let metrics = tab
|
||||
.scene
|
||||
.meshes
|
||||
|
||||
@@ -3771,6 +3771,9 @@ pub enum Message {
|
||||
// ── PDF Underlay ──────────────────────────────────────────────────────
|
||||
/// Open file-picker dialog for PDFATTACH command (async).
|
||||
PdfAttachPick,
|
||||
/// PDFIMPORT File: pick the PDF to import.
|
||||
PdfImportPick,
|
||||
PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc<Vec<u8>>), String>),
|
||||
/// Result of the PDFATTACH file picker.
|
||||
PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc<Vec<u8>>), String>),
|
||||
|
||||
|
||||
+28
-18
@@ -1479,25 +1479,29 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Underlay: name + path from the referenced definition.
|
||||
// Underlay: name, page, path and size from the definition.
|
||||
acadrust::EntityType::Underlay(ul) => {
|
||||
if let Some((name, path)) =
|
||||
doc.objects.iter().find_map(|(h, o)| match o {
|
||||
acadrust::objects::ObjectType::UnderlayDefinition(def)
|
||||
if *h == ul.definition_handle =>
|
||||
{
|
||||
let nm = if !def.name.is_empty() {
|
||||
def.name.clone()
|
||||
} else {
|
||||
def.page_name.clone()
|
||||
};
|
||||
Some((nm, def.file_path.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
set_row(&mut sections, "ul_name", name);
|
||||
set_row(&mut sections, "ul_path", path);
|
||||
use crate::entities::underlay as und;
|
||||
if let Some(def) = und::definition(ul, doc) {
|
||||
set_row(&mut sections, "ul_name", und::definition_display_name(def));
|
||||
set_row(&mut sections, "ul_page", und::page_of(def).to_string());
|
||||
set_row(
|
||||
&mut sections,
|
||||
"ul_path",
|
||||
und::display_path(&def.file_path),
|
||||
);
|
||||
}
|
||||
if let Some((w, h)) = und::shown_size(ul, doc) {
|
||||
set_row_value(
|
||||
&mut sections,
|
||||
"ul_width",
|
||||
crate::scene::model::object::PropValue::EditText(und::plain_number(w)),
|
||||
);
|
||||
set_row_value(
|
||||
&mut sections,
|
||||
"ul_height",
|
||||
crate::scene::model::object::PropValue::EditText(und::plain_number(h)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Leader: text style / vertical text placement / overall
|
||||
@@ -2336,6 +2340,12 @@ impl OpenCADStudio {
|
||||
acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::Angular3Pt(_),
|
||||
) => t!("3 Point Angular Dimension").into_owned(),
|
||||
acadrust::EntityType::Underlay(underlay) => match underlay.underlay_type {
|
||||
acadrust::entities::UnderlayType::Pdf => t!("PDF Underlay"),
|
||||
acadrust::entities::UnderlayType::Dwf => t!("DWF Underlay"),
|
||||
acadrust::entities::UnderlayType::Dgn => t!("DGN Underlay"),
|
||||
}
|
||||
.into_owned(),
|
||||
_ => entity_type_label(entity),
|
||||
};
|
||||
// A dynamic dimension shows only its constraint and text
|
||||
|
||||
@@ -3453,6 +3453,38 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
} else {
|
||||
raw_val
|
||||
};
|
||||
// Underlay rows: reject what the reference rejects, and
|
||||
// write a Width / Height as the scale that produces it.
|
||||
if field.starts_with("ul_") {
|
||||
if let Err(message) =
|
||||
crate::entities::underlay::validate_property(field, &val)
|
||||
{
|
||||
self.command_line.push_error(message);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
let (field, val) = match field {
|
||||
"ul_width" | "ul_height" => {
|
||||
let scale = handles.iter().find_map(|handle| {
|
||||
match self.tabs[i].scene.document.get_entity(*handle) {
|
||||
Some(acadrust::EntityType::Underlay(underlay)) => {
|
||||
crate::entities::underlay::size_to_scale(
|
||||
underlay,
|
||||
&self.tabs[i].scene.document,
|
||||
field,
|
||||
&val,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
match scale {
|
||||
Some(scale) => ("ul_scale", scale),
|
||||
None => return Task::none(),
|
||||
}
|
||||
}
|
||||
_ => (field, val),
|
||||
};
|
||||
if matches!(
|
||||
field,
|
||||
"current_fit_point" | "current_control_point" | "pm_current_vertex"
|
||||
|
||||
+33
-13
@@ -1256,23 +1256,14 @@ impl OpenCADStudio {
|
||||
Message::PdfAttachPickResult(Ok((path, bytes))) => {
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::insert::pdf_attach::PdfAttachCommand;
|
||||
use acadrust::objects::{ObjectType, UnderlayDefinition};
|
||||
|
||||
let i = self.active_tab;
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
crate::scene::model::pdf_raster::register_source(&path_str, bytes);
|
||||
|
||||
let definition_handle = self.tabs[i].scene.document.allocate_handle();
|
||||
|
||||
let mut definition = UnderlayDefinition::pdf(&path_str, "1");
|
||||
definition.handle = definition_handle;
|
||||
|
||||
self.tabs[i].scene.document.objects.insert(
|
||||
definition_handle,
|
||||
ObjectType::UnderlayDefinition(definition),
|
||||
);
|
||||
|
||||
let cmd = PdfAttachCommand::new(definition_handle);
|
||||
// The definition is created with the underlay when it is
|
||||
// placed; the command asks for the page first.
|
||||
let insunits = self.tabs[i].scene.document.header.insertion_units;
|
||||
let cmd = PdfAttachCommand::with_file(&path_str, insunits);
|
||||
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
@@ -1280,6 +1271,35 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::PdfImportPick => Task::perform(
|
||||
async {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select PDF File").as_ref())
|
||||
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => {
|
||||
let path = crate::sys::handle_path(&h);
|
||||
let bytes = std::sync::Arc::new(h.read().await);
|
||||
Ok((path, bytes))
|
||||
}
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
Message::PdfImportPickResult,
|
||||
),
|
||||
Message::PdfImportPickResult(Ok((path, bytes))) => {
|
||||
let i = self.active_tab;
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
crate::scene::model::pdf_raster::register_source(&path_str, bytes);
|
||||
self.run_pdf_import(
|
||||
i,
|
||||
crate::app::commands::pdf_import::PdfImportSource::File(path_str),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::PdfImportPickResult(Err(_)) => Task::none(),
|
||||
Message::PdfAttachPickResult(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
self.command_line
|
||||
|
||||
@@ -1556,6 +1556,8 @@ pub enum CmdResult {
|
||||
BatchCopy(Vec<Handle>, Vec<EntityTransform>),
|
||||
/// Erase `handle` and replace with new entities; command stays active.
|
||||
ReplaceEntity(Handle, Vec<EntityType>),
|
||||
/// Import the vector content of a PDF underlay (PDFIMPORT); ends the command.
|
||||
PdfImport(crate::modules::insert::pdf_import::PdfImportRequest),
|
||||
/// Update one entity in place, preserve its handle, and end the command.
|
||||
UpdateEntityAndFinish {
|
||||
handle: Handle,
|
||||
@@ -2844,6 +2846,12 @@ pub trait CadCommand: Send {
|
||||
None
|
||||
}
|
||||
|
||||
/// If this command attaches a PDF page: its file and page. The host
|
||||
/// creates (or reuses) the definition with the committed underlay.
|
||||
fn pdf_attach_source(&self) -> Option<(String, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The block reference an ATTEDIT pick has resolved to, awaiting the
|
||||
/// attribute editor dialog; else None.
|
||||
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
|
||||
|
||||
+305
-206
@@ -1,15 +1,18 @@
|
||||
// Underlay entity — PDF/DWF/DGN reference.
|
||||
//
|
||||
// Render: clip boundary polygon (or cross at insertion if no boundary).
|
||||
// Render: the visible outline (clip polygon, or the page frame) with a pick
|
||||
// surface over it; a cross at the insertion when the page is unknown.
|
||||
// Grips: insertion point + clip boundary vertices.
|
||||
// Props: position, scales, rotation, contrast, fade, flags.
|
||||
// Props: General → Underlay Adjust → Geometry → Misc, as the reference lists
|
||||
// a PDF underlay. Rows that need the definition (name, page, path,
|
||||
// width, height) are filled in by the Properties panel.
|
||||
|
||||
use acadrust::entities::{Underlay, UnderlayDisplayFlags};
|
||||
use crate::t;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::EntityTransform;
|
||||
use crate::entities::common::{center_grip, edit_angle_prop as edit_angle, edit_prop as edit, ro_prop as ro, square_grip};
|
||||
use crate::entities::common::{center_grip, ro_prop as ro, square_grip};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
||||
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
|
||||
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
|
||||
@@ -21,11 +24,7 @@ fn v3(v: &acadrust::types::Vector3) -> [f64; 3] {
|
||||
[v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
fn v3f32(v: &acadrust::types::Vector3) -> [f32; 3] {
|
||||
[v.x as f32, v.y as f32, v.z as f32]
|
||||
}
|
||||
|
||||
/// Small cross marker at the insertion point (used when no clip boundary).
|
||||
/// Small cross marker at the insertion point (used when the page is unknown).
|
||||
fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> {
|
||||
let [ox, oy, oz] = origin;
|
||||
vec![
|
||||
@@ -37,92 +36,275 @@ fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> {
|
||||
]
|
||||
}
|
||||
|
||||
// ── RenderConvertible ──────────────────────────────────────────────────────────
|
||||
|
||||
/// The underlay's page rectangle in world space (CCW from the insertion),
|
||||
/// when its definition resolves and the page rasterises: page inches × the
|
||||
/// entity scale, rotated about the insertion. `None` keeps the cross
|
||||
/// placeholder.
|
||||
fn page_quad(
|
||||
/// The underlay's definition, when the handle resolves to one.
|
||||
pub(crate) fn definition<'a>(
|
||||
u: &Underlay,
|
||||
document: &acadrust::CadDocument,
|
||||
) -> Option<[[f64; 3]; 4]> {
|
||||
let def = match document.objects.get(&u.definition_handle) {
|
||||
Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => d,
|
||||
_ => return None,
|
||||
};
|
||||
let page = if def.page_name.trim().is_empty() {
|
||||
document: &'a acadrust::CadDocument,
|
||||
) -> Option<&'a acadrust::entities::UnderlayDefinition> {
|
||||
match document.objects.get(&u.definition_handle) {
|
||||
Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Page number of a definition ("1" when unset).
|
||||
pub(crate) fn page_of(def: &acadrust::entities::UnderlayDefinition) -> &str {
|
||||
if def.page_name.trim().is_empty() {
|
||||
"1"
|
||||
} else {
|
||||
def.page_name.trim()
|
||||
};
|
||||
let raster = crate::scene::model::pdf_raster::rasterize_page(&def.file_path, page)?;
|
||||
let w = raster.width as f64 / raster.dpi as f64 * u.x_scale;
|
||||
let h = raster.height as f64 / raster.dpi as f64 * u.y_scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of the referenced page in underlay units (page inches), when the
|
||||
/// definition resolves and its PDF page can be read.
|
||||
pub(crate) fn page_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
|
||||
let def = definition(u, document)?;
|
||||
if !matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf) {
|
||||
return None;
|
||||
}
|
||||
crate::scene::model::pdf_raster::page_size_inches(&def.file_path, page_of(def))
|
||||
}
|
||||
|
||||
/// The name a definition shows: its own name, or "<file> - <page>" as the
|
||||
/// reference names a PDF definition.
|
||||
pub(crate) fn definition_display_name(def: &acadrust::entities::UnderlayDefinition) -> String {
|
||||
if !def.name.trim().is_empty() {
|
||||
return def.name.clone();
|
||||
}
|
||||
let stem = std::path::Path::new(&def.file_path.replace('\\', "/"))
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
format!("{stem} - {}", page_of(def))
|
||||
}
|
||||
|
||||
/// A stored path as the platform writes it (backslashes on Windows).
|
||||
pub(crate) fn display_path(path: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
path.replace('/', "\\")
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip polygon in underlay units (page inches). Two stored vertices are the
|
||||
/// opposite corners of a rectangle.
|
||||
pub(crate) fn clip_polygon_local(u: &Underlay) -> Vec<[f64; 2]> {
|
||||
let v = &u.clip_boundary_vertices;
|
||||
if v.len() == 2 {
|
||||
let (a, b) = (&v[0], &v[1]);
|
||||
return vec![[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]];
|
||||
}
|
||||
v.iter().map(|p| [p.x, p.y]).collect()
|
||||
}
|
||||
|
||||
/// Underlay units → world, through scale, rotation and insertion.
|
||||
pub(crate) fn local_to_world(u: &Underlay, p: [f64; 2]) -> [f64; 3] {
|
||||
let (c, s) = (u.rotation.cos(), u.rotation.sin());
|
||||
let x = p[0] * u.x_scale;
|
||||
let y = p[1] * u.y_scale;
|
||||
let o = &u.insertion_point;
|
||||
let ux = (c * w, s * w);
|
||||
let vx = (-s * h, c * h);
|
||||
[o.x + x * c - y * s, o.y + x * s + y * c, o.z]
|
||||
}
|
||||
|
||||
/// Whether a clip boundary limits what is shown.
|
||||
pub(crate) fn is_clipped(u: &Underlay) -> bool {
|
||||
u.flags.contains(UnderlayDisplayFlags::CLIPPING) && clip_polygon_local(u).len() >= 3
|
||||
}
|
||||
|
||||
/// Page frame in world space (CCW from the insertion).
|
||||
fn page_quad(u: &Underlay, document: &acadrust::CadDocument) -> Option<[[f64; 3]; 4]> {
|
||||
let (w, h) = page_size(u, document)?;
|
||||
Some([
|
||||
[o.x, o.y, o.z],
|
||||
[o.x + ux.0, o.y + ux.1, o.z],
|
||||
[o.x + ux.0 + vx.0, o.y + ux.1 + vx.1, o.z],
|
||||
[o.x + vx.0, o.y + vx.1, o.z],
|
||||
local_to_world(u, [0.0, 0.0]),
|
||||
local_to_world(u, [w, 0.0]),
|
||||
local_to_world(u, [w, h]),
|
||||
local_to_world(u, [0.0, h]),
|
||||
])
|
||||
}
|
||||
|
||||
impl RenderConvertible for Underlay {
|
||||
fn to_render(&self, _document: &acadrust::CadDocument) -> Option<RenderEntity> {
|
||||
let origin = v3(&self.insertion_point);
|
||||
let _origin_f32 = v3f32(&self.insertion_point);
|
||||
/// Local-space extent of what is shown: the clip polygon's box when clipped
|
||||
/// to its inside, else the page.
|
||||
fn shown_local_extent(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
|
||||
if is_clipped(u) && !u.clip_inverted {
|
||||
let clip = clip_polygon_local(u);
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for p in &clip {
|
||||
lo = [lo[0].min(p[0]), lo[1].min(p[1])];
|
||||
hi = [hi[0].max(p[0]), hi[1].max(p[1])];
|
||||
}
|
||||
return Some((hi[0] - lo[0], hi[1] - lo[1]));
|
||||
}
|
||||
page_size(u, document)
|
||||
}
|
||||
|
||||
if !self.clip_boundary_vertices.is_empty() {
|
||||
// Draw clip boundary polygon + close it.
|
||||
let world_verts = self.world_clip_boundary();
|
||||
let mut pts: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect();
|
||||
// Close polygon.
|
||||
if let Some(&first) = pts.first() {
|
||||
pts.push(first);
|
||||
}
|
||||
// Insertion grip.
|
||||
let key: Vec<[f64; 3]> = pts.clone();
|
||||
// Interior pick surface over the clip polygon so the underlay
|
||||
// selects on a click anywhere inside, not just on its outline.
|
||||
let ring: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect();
|
||||
let pick_tris = crate::entities::mesh::triangulate_planar(&ring);
|
||||
Some(RenderEntity {
|
||||
pick_tris,
|
||||
object: RenderObject::Lines(pts),
|
||||
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: key,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
} else if let Some(q) = page_quad(self, _document) {
|
||||
// Rendered page: draw its frame so selection/pick cover the
|
||||
// visible extent instead of a lone cross under the raster.
|
||||
let pts = vec![q[0], q[1], q[2], q[3], q[0]];
|
||||
let pick_tris = crate::entities::mesh::triangulate_planar(&q.to_vec());
|
||||
Some(RenderEntity {
|
||||
pick_tris,
|
||||
object: RenderObject::Lines(pts),
|
||||
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: q.to_vec(),
|
||||
fill_tris: vec![],
|
||||
})
|
||||
/// Width and height the Properties panel shows: the shown extent times the
|
||||
/// scale (the clip's size once clipped, as the reference reports it).
|
||||
pub(crate) fn shown_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> {
|
||||
shown_local_extent(u, document).map(|(w, h)| (w * u.x_scale.abs(), h * u.y_scale.abs()))
|
||||
}
|
||||
|
||||
/// World bounds of the underlay: the clip polygon when clipped to its
|
||||
/// inside, else the page frame. `None` when the page is unknown.
|
||||
pub(crate) fn world_bounds(
|
||||
u: &Underlay,
|
||||
document: &acadrust::CadDocument,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let outline: Vec<[f64; 3]> = if is_clipped(u) && !u.clip_inverted {
|
||||
clip_polygon_local(u).into_iter().map(|p| local_to_world(u, p)).collect()
|
||||
} else {
|
||||
page_quad(u, document)?.to_vec()
|
||||
};
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for p in outline {
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(p[k]);
|
||||
max[k] = max[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
Some((min, max))
|
||||
}
|
||||
|
||||
/// A number as the reference Properties palette shows underlay values: up to
|
||||
/// four decimals, trailing zeros dropped.
|
||||
pub(crate) fn plain_number(value: f64) -> String {
|
||||
let text = format!("{value:.4}");
|
||||
let text = text.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
if text == "-0" { "0".to_string() } else { text }
|
||||
}
|
||||
|
||||
fn number_row(label: &str, field: &'static str, value: f64) -> Property {
|
||||
Property {
|
||||
label: label.into(),
|
||||
field,
|
||||
value: PropValue::EditText(plain_number(value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(label: &str, field: &'static str, flag: bool) -> Property {
|
||||
Property {
|
||||
label: label.into(),
|
||||
field,
|
||||
value: PropValue::Choice {
|
||||
selected: if flag { t!("Yes") } else { t!("No") }.into_owned(),
|
||||
options: vec![t!("Yes").into_owned(), t!("No").into_owned()],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A Yes/No list value (localized label, the English word, or a toggle).
|
||||
fn parse_flag(value: &str, current: bool) -> bool {
|
||||
let v = value.trim();
|
||||
if v == "toggle" {
|
||||
return !current;
|
||||
}
|
||||
v == t!("Yes").as_ref() || v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
/// Checks a value typed into an underlay row before it is written. The
|
||||
/// messages are the reference's.
|
||||
pub(crate) fn validate_property(field: &str, value: &str) -> Result<(), &'static str> {
|
||||
let number = || value.trim().parse::<f64>().ok();
|
||||
match field {
|
||||
"ul_contrast" | "ul_fade" => match number() {
|
||||
Some(v) if (0.0..=100.0).contains(&v) && v.fract() == 0.0 => Ok(()),
|
||||
_ => Err("Value must be an integer between 0 and 100"),
|
||||
},
|
||||
"ul_scale" | "ul_width" | "ul_height" => match number() {
|
||||
Some(v) if v > 0.0 => Ok(()),
|
||||
_ => Err("Value must be positive and nonzero."),
|
||||
},
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a Width / Height write into the scale that produces it, since the
|
||||
/// size itself is the page (or clip) size times the scale.
|
||||
pub(crate) fn size_to_scale(
|
||||
u: &Underlay,
|
||||
document: &acadrust::CadDocument,
|
||||
field: &str,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let v = value.trim().parse::<f64>().ok()?;
|
||||
let (w, h) = shown_local_extent(u, document)?;
|
||||
let base = match field {
|
||||
"ul_width" => w,
|
||||
"ul_height" => h,
|
||||
_ => return None,
|
||||
};
|
||||
(base > 0.0).then(|| (v / base).to_string())
|
||||
}
|
||||
|
||||
// ── RenderConvertible ──────────────────────────────────────────────────────────
|
||||
|
||||
impl RenderConvertible for Underlay {
|
||||
fn to_render(&self, document: &acadrust::CadDocument) -> Option<RenderEntity> {
|
||||
let origin = v3(&self.insertion_point);
|
||||
let insertion_snap = (
|
||||
DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z),
|
||||
SnapHint::Insertion,
|
||||
);
|
||||
let quad = page_quad(self, document);
|
||||
let clip: Vec<[f64; 3]> = if is_clipped(self) {
|
||||
clip_polygon_local(self)
|
||||
.into_iter()
|
||||
.map(|p| local_to_world(self, p))
|
||||
.collect()
|
||||
} else {
|
||||
// No clip boundary: draw a cross at insertion point.
|
||||
let pts = cross_wire(origin, 1.0);
|
||||
Some(RenderEntity {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// The outline is the clip boundary when clipped (plus the page frame
|
||||
// for an outside clip), else the page frame.
|
||||
let mut pts: Vec<[f64; 3]> = Vec::new();
|
||||
let ring = |poly: &[[f64; 3]], pts: &mut Vec<[f64; 3]>| {
|
||||
if !pts.is_empty() {
|
||||
pts.push([f64::NAN; 3]);
|
||||
}
|
||||
pts.extend_from_slice(poly);
|
||||
pts.push(poly[0]);
|
||||
};
|
||||
if !clip.is_empty() {
|
||||
ring(&clip, &mut pts);
|
||||
if self.clip_inverted {
|
||||
if let Some(q) = quad {
|
||||
ring(&q, &mut pts);
|
||||
}
|
||||
}
|
||||
} else if let Some(q) = quad {
|
||||
ring(&q, &mut pts);
|
||||
}
|
||||
if pts.is_empty() {
|
||||
return Some(RenderEntity {
|
||||
pick_tris: Vec::new(),
|
||||
object: RenderObject::Lines(pts),
|
||||
snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)],
|
||||
object: RenderObject::Lines(cross_wire(origin, 1.0)),
|
||||
snap_pts: vec![insertion_snap],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![origin],
|
||||
fill_tris: vec![],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Pick surface over the shown area so a click inside selects it.
|
||||
let pick_ring: Vec<[f64; 3]> = if !clip.is_empty() && !self.clip_inverted {
|
||||
clip.clone()
|
||||
} else {
|
||||
quad.map(|q| q.to_vec()).unwrap_or_default()
|
||||
};
|
||||
let pick_tris = crate::entities::mesh::triangulate_planar(&pick_ring);
|
||||
let mut snap_pts = vec![insertion_snap];
|
||||
snap_pts.extend(crate::scene::model::pdf_vector::underlay_snap_points(self, document));
|
||||
Some(RenderEntity {
|
||||
pick_tris,
|
||||
object: RenderObject::Lines(pts),
|
||||
snap_pts,
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: pick_ring,
|
||||
fill_tris: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,25 +382,6 @@ impl Grippable for Underlay {
|
||||
|
||||
impl PropertyEditable for Underlay {
|
||||
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
|
||||
// Width / Height derived from the world-space clip boundary bounds
|
||||
// (the only in-entity source of the placed footprint size).
|
||||
let (width, height) = if self.clip_boundary_vertices.is_empty() {
|
||||
(0.0_f64, 0.0_f64)
|
||||
} else {
|
||||
let verts = self.world_clip_boundary();
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
for v in &verts {
|
||||
min_x = min_x.min(v.x);
|
||||
min_y = min_y.min(v.y);
|
||||
max_x = max_x.max(v.x);
|
||||
max_y = max_y.max(v.y);
|
||||
}
|
||||
(max_x - min_x, max_y - min_y)
|
||||
};
|
||||
|
||||
let show = self.flags.contains(UnderlayDisplayFlags::ON);
|
||||
let clipping = self.flags.contains(UnderlayDisplayFlags::CLIPPING);
|
||||
let monochrome = self.flags.contains(UnderlayDisplayFlags::MONOCHROME);
|
||||
@@ -226,74 +389,38 @@ impl PropertyEditable for Underlay {
|
||||
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Geometry").into_owned(),
|
||||
title: t!("Underlay Adjust").into_owned(),
|
||||
props: vec![
|
||||
edit(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x),
|
||||
edit(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y),
|
||||
edit(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z),
|
||||
edit(t!("Scale X").as_ref(), "ul_sx", self.x_scale),
|
||||
edit(t!("Scale Y").as_ref(), "ul_sy", self.y_scale),
|
||||
edit(t!("Scale Z").as_ref(), "ul_sz", self.z_scale),
|
||||
ro(t!("Width").as_ref(), "ul_width", crate::entities::common::format_length(width)),
|
||||
ro(t!("Height").as_ref(), "ul_height", crate::entities::common::format_length(height)),
|
||||
edit_angle(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()),
|
||||
number_row(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64),
|
||||
number_row(t!("Fade").as_ref(), "ul_fade", self.fade as f64),
|
||||
yes_no(t!("Monochrome").as_ref(), "ul_mono", monochrome),
|
||||
yes_no(t!("Adjust colors for background").as_ref(), "ul_adjust_bg", adjust_bg),
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Underlay Adjust").into_owned(),
|
||||
title: t!("Geometry").into_owned(),
|
||||
props: vec![
|
||||
edit(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64),
|
||||
edit(t!("Fade").as_ref(), "ul_fade", self.fade as f64),
|
||||
Property {
|
||||
label: t!("Monochrome").into_owned(),
|
||||
field: "ul_mono",
|
||||
value: PropValue::BoolToggle {
|
||||
field: "ul_mono",
|
||||
value: monochrome,
|
||||
},
|
||||
},
|
||||
Property {
|
||||
label: t!("Adjust Colors for Background").into_owned(),
|
||||
field: "ul_adjust_bg",
|
||||
value: PropValue::BoolToggle {
|
||||
field: "ul_adjust_bg",
|
||||
value: adjust_bg,
|
||||
},
|
||||
},
|
||||
number_row(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x),
|
||||
number_row(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y),
|
||||
number_row(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z),
|
||||
number_row(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()),
|
||||
// Filled from the page size by the Properties panel.
|
||||
number_row(t!("Width").as_ref(), "ul_width", 0.0),
|
||||
number_row(t!("Height").as_ref(), "ul_height", 0.0),
|
||||
number_row(t!("Scale").as_ref(), "ul_scale", self.x_scale),
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Misc").into_owned(),
|
||||
props: vec![
|
||||
// Underlay name/path/layers live on the separate
|
||||
// UnderlayDefinition object, not reachable from the entity.
|
||||
ro(t!("Underlay name").as_ref(), "ul_name", String::new()),
|
||||
ro(t!("Underlay path").as_ref(), "ul_path", String::new()),
|
||||
Property {
|
||||
label: t!("Show underlay").into_owned(),
|
||||
field: "ul_on",
|
||||
value: PropValue::BoolToggle {
|
||||
field: "ul_on",
|
||||
value: show,
|
||||
},
|
||||
},
|
||||
Property {
|
||||
label: t!("Clipping").into_owned(),
|
||||
field: "ul_clip",
|
||||
value: PropValue::BoolToggle {
|
||||
field: "ul_clip",
|
||||
value: clipping,
|
||||
},
|
||||
},
|
||||
Property {
|
||||
label: t!("Show clipped").into_owned(),
|
||||
field: "ul_clip_inverted",
|
||||
value: PropValue::BoolToggle {
|
||||
field: "ul_clip_inverted",
|
||||
value: self.clip_inverted,
|
||||
},
|
||||
},
|
||||
ro(t!("Underlay layers").as_ref(), "ul_layers", String::new()),
|
||||
// Name, page and path live on the UnderlayDefinition and
|
||||
// are filled in by the Properties panel.
|
||||
ro(t!("Name").as_ref(), "ul_name", String::new()),
|
||||
ro(t!("Page number").as_ref(), "ul_page", String::new()),
|
||||
ro(t!("Saved Path").as_ref(), "ul_path", String::new()),
|
||||
yes_no(t!("Show underlay").as_ref(), "ul_on", show),
|
||||
yes_no(t!("Show clipped").as_ref(), "ul_clip", clipping),
|
||||
ro(t!("Layer display overrides").as_ref(), "ul_layers", t!("None").into_owned()),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -302,73 +429,45 @@ impl PropertyEditable for Underlay {
|
||||
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
||||
match field {
|
||||
"ul_mono" => {
|
||||
let on = if value == "toggle" {
|
||||
!self.flags.contains(UnderlayDisplayFlags::MONOCHROME)
|
||||
} else {
|
||||
value == "true"
|
||||
};
|
||||
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::MONOCHROME));
|
||||
self.set_monochrome(on);
|
||||
return;
|
||||
}
|
||||
"ul_adjust_bg" => {
|
||||
let on = if value == "toggle" {
|
||||
!self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND)
|
||||
} else {
|
||||
value == "true"
|
||||
};
|
||||
if on {
|
||||
self.flags |= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
|
||||
} else {
|
||||
self.flags -= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
|
||||
}
|
||||
let on = parse_flag(
|
||||
value,
|
||||
self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND),
|
||||
);
|
||||
self.flags.set(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND, on);
|
||||
return;
|
||||
}
|
||||
"ul_on" => {
|
||||
let on = if value == "toggle" {
|
||||
!self.flags.contains(UnderlayDisplayFlags::ON)
|
||||
} else {
|
||||
value == "true"
|
||||
};
|
||||
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::ON));
|
||||
self.set_on(on);
|
||||
return;
|
||||
}
|
||||
"ul_clip" => {
|
||||
let on = if value == "toggle" {
|
||||
!self.flags.contains(UnderlayDisplayFlags::CLIPPING)
|
||||
} else {
|
||||
value == "true"
|
||||
};
|
||||
if on {
|
||||
self.flags |= UnderlayDisplayFlags::CLIPPING;
|
||||
} else {
|
||||
self.flags -= UnderlayDisplayFlags::CLIPPING;
|
||||
}
|
||||
return;
|
||||
}
|
||||
"ul_clip_inverted" => {
|
||||
let on = if value == "toggle" {
|
||||
!self.clip_inverted
|
||||
} else {
|
||||
value == "true"
|
||||
};
|
||||
self.clip_inverted = on;
|
||||
let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::CLIPPING));
|
||||
self.flags.set(UnderlayDisplayFlags::CLIPPING, on);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(v) = value.trim().parse::<f64>() {
|
||||
match field {
|
||||
"ul_ix" => self.insertion_point.x = v,
|
||||
"ul_iy" => self.insertion_point.y = v,
|
||||
"ul_iz" => self.insertion_point.z = v,
|
||||
"ul_sx" => self.x_scale = v,
|
||||
"ul_sy" => self.y_scale = v,
|
||||
"ul_sz" => self.z_scale = v,
|
||||
"ul_rot" => self.rotation = v.to_radians(),
|
||||
"ul_contrast" => self.set_contrast(v.clamp(0.0, 100.0) as u8),
|
||||
"ul_fade" => self.set_fade(v.clamp(0.0, 80.0) as u8),
|
||||
_ => {}
|
||||
}
|
||||
if validate_property(field, value).is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(v) = crate::entities::common::parse_f64(value) else {
|
||||
return;
|
||||
};
|
||||
match field {
|
||||
"ul_ix" => self.insertion_point.x = v,
|
||||
"ul_iy" => self.insertion_point.y = v,
|
||||
"ul_iz" => self.insertion_point.z = v,
|
||||
"ul_scale" => self.set_scale(v),
|
||||
"ul_rot" => self.rotation = v.to_radians(),
|
||||
"ul_contrast" => self.set_contrast(v as u8),
|
||||
"ul_fade" => self.set_fade(v as u8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2911,6 +2911,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Unable to verify the server certificate" => Some(("plugins", "unable-to-verify-the-server-certificate")),
|
||||
"Unavailable" => Some(("plugins", "unavailable")),
|
||||
"Underlay" => Some(("properties", "underlay")),
|
||||
"PDF Underlay" => Some(("properties", "pdf-underlay")),
|
||||
"DWF Underlay" => Some(("properties", "dwf-underlay")),
|
||||
"DGN Underlay" => Some(("properties", "dgn-underlay")),
|
||||
"Adjust colors for background" => Some(("properties", "adjust-colors-for-background")),
|
||||
"Page number" => Some(("properties", "page-number")),
|
||||
"Layer display overrides" => Some(("properties", "layer-display-overrides")),
|
||||
"Select PDF File" => Some(("insert", "select-pdf-file")),
|
||||
"Underlay\nLayers" => Some(("insert", "underlay-layers")),
|
||||
"Underlay Adjust" => Some(("properties", "underlay-adjust")),
|
||||
"Underlay layers" => Some(("properties", "underlay-layers")),
|
||||
|
||||
@@ -17,6 +17,8 @@ mod mview_block;
|
||||
mod open_obj;
|
||||
mod pc_attach;
|
||||
pub(crate) mod pdf_attach;
|
||||
pub(crate) mod pdf_clip;
|
||||
pub(crate) mod pdf_import;
|
||||
mod snap_underlays;
|
||||
pub(crate) mod solid3d_cmds;
|
||||
mod underlay_layers;
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use acadrust::entities::Underlay;
|
||||
// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay.
|
||||
//
|
||||
// Path to PDF file to attach: (-PDFATTACH only)
|
||||
// Enter page number or [?] <1>:
|
||||
// Specify insertion point:
|
||||
// Base image size: Width: 7.8740, Height: 3.9369, Meters
|
||||
// Specify scale factor or [Unit] <1>:
|
||||
// Specify rotation <0>:
|
||||
//
|
||||
// The definition (one per file and page, named "<file> - <page>" in the
|
||||
// ACAD_PDFDEFINITIONS dictionary) is created with the underlay when it is
|
||||
// placed, so a cancelled attach leaves nothing and undo removes both.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use acadrust::entities::{Underlay, UnderlayDisplayFlags};
|
||||
use acadrust::objects::{Dictionary, ObjectType, UnderlayDefinition};
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use acadrust::{CadDocument, EntityType};
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::t;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const ICON: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../assets/icons/underlay_layers.svg"));
|
||||
@@ -19,18 +35,254 @@ pub fn tool() -> ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Units the Unit option offers: keyword, the name the prompt shows.
|
||||
const UNITS: [(&str, &str); 9] = [
|
||||
("MM", "Millimeters"),
|
||||
("Centimeter", "Centimeters"),
|
||||
("Meter", "Meters"),
|
||||
("Kilometer", "Kilometers"),
|
||||
("Inch", "Inches"),
|
||||
("Foot", "Feet"),
|
||||
("Yard", "Yards"),
|
||||
("MILe", "Miles"),
|
||||
("Unitless", "Unitless"),
|
||||
];
|
||||
|
||||
/// The unit last chosen with the Unit option; it stays the default.
|
||||
static LAST_UNIT: Mutex<Option<usize>> = Mutex::new(None);
|
||||
|
||||
/// The Unit option's index for the drawing's INSUNITS.
|
||||
fn unit_for_insunits(insunits: i16) -> usize {
|
||||
match insunits {
|
||||
4 => 0,
|
||||
5 => 1,
|
||||
6 => 2,
|
||||
7 => 3,
|
||||
1 => 4,
|
||||
2 => 5,
|
||||
10 => 6,
|
||||
3 => 7,
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_from_keyword(text: &str) -> Option<usize> {
|
||||
let t = text.trim().to_ascii_uppercase();
|
||||
let short = match t.as_str() {
|
||||
"MM" => Some(0),
|
||||
"C" => Some(1),
|
||||
"M" => Some(2),
|
||||
"K" => Some(3),
|
||||
"I" => Some(4),
|
||||
"F" => Some(5),
|
||||
"Y" => Some(6),
|
||||
"MIL" => Some(7),
|
||||
"U" => Some(8),
|
||||
_ => None,
|
||||
};
|
||||
short.or_else(|| {
|
||||
UNITS.iter().position(|(keyword, _)| {
|
||||
let k = keyword.to_ascii_uppercase();
|
||||
t.len() >= 2 && k.starts_with(&t)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Path,
|
||||
Page,
|
||||
ListPages,
|
||||
Insertion,
|
||||
Scale,
|
||||
Unit,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
pub struct PdfAttachCommand {
|
||||
definition_handle: Handle,
|
||||
step: Step,
|
||||
path: String,
|
||||
page: String,
|
||||
page_count: usize,
|
||||
page_size: (f64, f64),
|
||||
insertion: DVec3,
|
||||
scale: f64,
|
||||
unit: usize,
|
||||
plane: WorkingPlane,
|
||||
}
|
||||
|
||||
impl PdfAttachCommand {
|
||||
pub fn new(definition_handle: Handle) -> Self {
|
||||
/// `-PDFATTACH`: starts by asking for the file.
|
||||
pub fn new(insunits: i16) -> Self {
|
||||
let unit = LAST_UNIT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|last| *last)
|
||||
.unwrap_or_else(|| unit_for_insunits(insunits));
|
||||
Self {
|
||||
definition_handle,
|
||||
step: Step::Path,
|
||||
path: String::new(),
|
||||
page: "1".to_string(),
|
||||
page_count: 0,
|
||||
page_size: (0.0, 0.0),
|
||||
insertion: DVec3::ZERO,
|
||||
scale: 1.0,
|
||||
unit,
|
||||
plane: WorkingPlane::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFATTACH with a file already chosen: starts at the page prompt.
|
||||
pub fn with_file(path: &str, insunits: i16) -> Self {
|
||||
let mut command = Self::new(insunits);
|
||||
if let Some(count) = crate::scene::model::pdf_raster::page_count(path) {
|
||||
command.path = path.to_string();
|
||||
command.page_count = count;
|
||||
command.step = Step::Page;
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn base_image_size(&self) -> String {
|
||||
format!(
|
||||
"Base image size: Width: {:.4}, Height: {:.4}, {}",
|
||||
self.page_size.0, self.page_size.1, UNITS[self.unit].1
|
||||
)
|
||||
}
|
||||
|
||||
fn accept_path(&mut self, text: &str) -> CmdResult {
|
||||
let mut path = text.trim().trim_matches('"').to_string();
|
||||
if path.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if std::path::Path::new(&path).extension().is_none() {
|
||||
path.push_str(".pdf");
|
||||
}
|
||||
match crate::scene::model::pdf_raster::page_count(&path) {
|
||||
Some(count) if count > 0 => {
|
||||
self.path = path;
|
||||
self.page_count = count;
|
||||
self.step = Step::Page;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError(format!(
|
||||
"{} not found.",
|
||||
crate::entities::underlay::display_path(&path)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_page(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
if text == "?" {
|
||||
self.step = Step::ListPages;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let page = if text.is_empty() { "1" } else { text };
|
||||
match page.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= self.page_count => {
|
||||
self.page = n.to_string();
|
||||
self.page_size = crate::scene::model::pdf_raster::page_size_inches(&self.path, &self.page)
|
||||
.unwrap_or((0.0, 0.0));
|
||||
self.step = Step::Insertion;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError(format!(
|
||||
"There is no {page} in {}.",
|
||||
crate::entities::underlay::display_path(&self.path)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_pages(&mut self, pattern: &str) -> CmdResult {
|
||||
let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() };
|
||||
let lines: Vec<String> = (1..=self.page_count)
|
||||
.map(|n| n.to_string())
|
||||
.filter(|n| crate::io::xref_model::wildcard_match(n, pattern))
|
||||
.collect();
|
||||
self.step = Step::Page;
|
||||
CmdResult::ReportMeasurement(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn accept_scale(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
self.step = Step::Rotation;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if text.eq_ignore_ascii_case("U") || text.eq_ignore_ascii_case("UNIT") {
|
||||
self.step = Step::Unit;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match crate::entities::common::parse_f64(text) {
|
||||
Some(v) if v > 0.0 => {
|
||||
self.scale = v;
|
||||
self.step = Step::Rotation;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(_) => CmdResult::ReportError("Value must be positive and nonzero.".to_string()),
|
||||
None => CmdResult::ReportError("Requires numeric value or option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_unit(&mut self, text: &str) -> CmdResult {
|
||||
let unit = if text.trim().is_empty() {
|
||||
Some(self.unit)
|
||||
} else {
|
||||
unit_from_keyword(text)
|
||||
};
|
||||
let Some(unit) = unit else {
|
||||
return CmdResult::ReportError("Invalid option keyword.".to_string());
|
||||
};
|
||||
self.unit = unit;
|
||||
if let Ok(mut last) = LAST_UNIT.lock() {
|
||||
*last = Some(unit);
|
||||
}
|
||||
self.step = Step::Scale;
|
||||
CmdResult::ReportMeasurement(self.base_image_size())
|
||||
}
|
||||
|
||||
fn accept_rotation(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
let degrees = if text.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
match crate::entities::common::parse_f64(text) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return CmdResult::ReportError(
|
||||
"Requires numeric angle or second point.".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.commit(degrees.to_radians())
|
||||
}
|
||||
|
||||
fn commit(&mut self, rotation: f64) -> CmdResult {
|
||||
let point = self.plane.to_local(self.insertion);
|
||||
let mut underlay = Underlay::pdf();
|
||||
underlay.insertion_point = Vector3::new(point.x, point.y, point.z);
|
||||
underlay.set_scale(self.scale);
|
||||
underlay.rotation = rotation;
|
||||
// On, clipped by its boundary and colour-adjusted for the background,
|
||||
// as the reference creates an underlay.
|
||||
underlay.flags = UnderlayDisplayFlags::ON
|
||||
| UnderlayDisplayFlags::CLIPPING
|
||||
| UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
|
||||
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Underlay(underlay)))
|
||||
}
|
||||
|
||||
fn frame_at(&self, pt: DVec3) -> Vec<[f32; 3]> {
|
||||
let (w, h) = (self.page_size.0 * self.scale, self.page_size.1 * self.scale);
|
||||
let x = self.plane.vector_to_world(DVec3::new(w, 0.0, 0.0));
|
||||
let y = self.plane.vector_to_world(DVec3::new(0.0, h, 0.0));
|
||||
let corners = [pt, pt + x, pt + x + y, pt + y, pt];
|
||||
corners
|
||||
.iter()
|
||||
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfAttachCommand {
|
||||
@@ -43,27 +295,165 @@ impl CadCommand for PdfAttachCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
t!("PDFATTACH Specify insertion point:").into_owned()
|
||||
match self.step {
|
||||
Step::Path => "Path to PDF file to attach:".to_string(),
|
||||
Step::Page => "Enter page number or [?] <1>:".to_string(),
|
||||
Step::ListPages => "Enter page(s) to list <*>:".to_string(),
|
||||
Step::Insertion => "Specify insertion point:".to_string(),
|
||||
Step::Scale => "Specify scale factor or [Unit] <1>:".to_string(),
|
||||
Step::Unit => format!(
|
||||
"Enter unit [MM/Centimeter/Meter/Kilometer/Inch/Foot/Yard/MILe/Unitless] <{}>:",
|
||||
UNITS[self.unit].0
|
||||
),
|
||||
Step::Rotation => "Specify rotation <0>:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Page => vec![CmdOption::new("?", "?")],
|
||||
Step::Scale => vec![CmdOption::new("Unit", "U")],
|
||||
Step::Unit => vec![
|
||||
CmdOption::new("MM", "MM"),
|
||||
CmdOption::new("Centimeter", "C"),
|
||||
CmdOption::new("Meter", "M"),
|
||||
CmdOption::new("Kilometer", "K"),
|
||||
CmdOption::new("Inch", "I"),
|
||||
CmdOption::new("Foot", "F"),
|
||||
CmdOption::new("Yard", "Y"),
|
||||
CmdOption::new("MILe", "MIL"),
|
||||
CmdOption::new("Unitless", "U"),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Path => InputKind::FreeText,
|
||||
Step::Insertion => InputKind::Point,
|
||||
_ => InputKind::SingleToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_attach_source(&self) -> Option<(String, String)> {
|
||||
(!self.path.is_empty()).then(|| (self.path.clone(), self.page.clone()))
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
let point = self.plane.to_local(pt);
|
||||
if self.step != Step::Insertion {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.insertion = pt;
|
||||
self.step = Step::Scale;
|
||||
CmdResult::ReportMeasurement(self.base_image_size())
|
||||
}
|
||||
|
||||
let mut underlay = Underlay::pdf();
|
||||
underlay.definition_handle = self.definition_handle;
|
||||
underlay.insertion_point = Vector3::new(point.x, point.y, point.z);
|
||||
|
||||
CmdResult::CommitAndExit(
|
||||
self.plane
|
||||
.place_entity(EntityType::Underlay(underlay)),
|
||||
)
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(match self.step {
|
||||
Step::Path => self.accept_path(text),
|
||||
Step::Page => self.accept_page(text),
|
||||
Step::ListPages => self.list_pages(text),
|
||||
Step::Insertion => return None,
|
||||
Step::Scale => self.accept_scale(text),
|
||||
Step::Unit => self.accept_unit(text),
|
||||
Step::Rotation => self.accept_rotation(text),
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.step {
|
||||
Step::Path => CmdResult::Cancel,
|
||||
Step::Page => self.accept_page(""),
|
||||
Step::ListPages => self.list_pages("*"),
|
||||
Step::Insertion => {
|
||||
CmdResult::ReportError("Point or option keyword required.".to_string())
|
||||
}
|
||||
Step::Scale => self.accept_scale(""),
|
||||
Step::Unit => self.accept_unit(""),
|
||||
Step::Rotation => self.accept_rotation(""),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
if self.step != Step::Insertion || self.page_size.0 <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(WireModel::solid(
|
||||
"pdf_attach_frame".into(),
|
||||
self.frame_at(pt),
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ACAD_PDFDEFINITIONS` dictionary under the named-objects root,
|
||||
/// created when the drawing has none.
|
||||
fn ensure_pdf_dictionary(document: &mut CadDocument) -> Option<Handle> {
|
||||
let root = document.header.named_objects_dict_handle;
|
||||
let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else {
|
||||
return None;
|
||||
};
|
||||
if let Some(existing) = root_dictionary.get("ACAD_PDFDEFINITIONS") {
|
||||
if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(existing);
|
||||
}
|
||||
}
|
||||
let handle = document.allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = handle;
|
||||
dictionary.owner = root;
|
||||
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
|
||||
root_dictionary.add_entry("ACAD_PDFDEFINITIONS", handle);
|
||||
}
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
fn same_path(a: &str, b: &str) -> bool {
|
||||
let norm = |p: &str| p.replace('\\', "/").to_lowercase();
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// The PDF definition for a file and page: the existing one, or a new one
|
||||
/// registered in ACAD_PDFDEFINITIONS as "<file> - <page>".
|
||||
pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) -> Handle {
|
||||
let existing = document.objects.iter().find_map(|(handle, object)| match object {
|
||||
ObjectType::UnderlayDefinition(def)
|
||||
if matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf)
|
||||
&& same_path(&def.file_path, path)
|
||||
&& crate::entities::underlay::page_of(def) == page =>
|
||||
{
|
||||
Some(*handle)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(handle) = existing {
|
||||
return handle;
|
||||
}
|
||||
let handle = document.allocate_handle();
|
||||
let mut definition = UnderlayDefinition::pdf(path, page);
|
||||
definition.handle = handle;
|
||||
let base = crate::entities::underlay::definition_display_name(&definition);
|
||||
if let Some(dictionary) = ensure_pdf_dictionary(document) {
|
||||
definition.owner_handle = dictionary;
|
||||
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) {
|
||||
let mut key = base.clone();
|
||||
let mut suffix = 1;
|
||||
while entries.get(&key).is_some() {
|
||||
suffix += 1;
|
||||
key = format!("{base}({suffix})");
|
||||
}
|
||||
entries.add_entry(key, handle);
|
||||
}
|
||||
}
|
||||
document
|
||||
.objects
|
||||
.insert(handle, ObjectType::UnderlayDefinition(definition));
|
||||
handle
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFATTACH"]
|
||||
names: &["PDFATTACH", "-PDFATTACH"]
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// PDFCLIP / CLIP — clip a PDF underlay to a boundary.
|
||||
//
|
||||
// Select PDF to clip: (CLIP: Select Object to clip:)
|
||||
// Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:
|
||||
// Delete old boundary? [Yes/No] <Yes>: (when one exists)
|
||||
// Outside mode - Objects outside boundary will be hidden.
|
||||
// Specify clipping boundary or select invert option:
|
||||
// [Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:
|
||||
//
|
||||
// The boundary is stored in underlay units; the clip-inside bit follows the
|
||||
// Invert choice.
|
||||
|
||||
use acadrust::entities::{Underlay, UnderlayDisplayFlags, UnderlayType};
|
||||
use acadrust::types::{Handle, Vector2};
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Select,
|
||||
Option,
|
||||
DeleteOld,
|
||||
Mode,
|
||||
RectFirst,
|
||||
RectSecond,
|
||||
PolyPoints,
|
||||
Polyline,
|
||||
}
|
||||
|
||||
pub struct PdfClipCommand {
|
||||
/// CLIP asks for any object; PDFCLIP for a PDF.
|
||||
generic: bool,
|
||||
step: Step,
|
||||
handle: Handle,
|
||||
underlay: Option<Underlay>,
|
||||
picked: Option<EntityType>,
|
||||
inverted: bool,
|
||||
points: Vec<DVec3>,
|
||||
}
|
||||
|
||||
const OUTSIDE: &str = "Outside mode - Objects outside boundary will be hidden.";
|
||||
const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden.";
|
||||
const BOUNDARY: &str = "Specify clipping boundary or select invert option:";
|
||||
|
||||
impl PdfClipCommand {
|
||||
pub fn new(generic: bool) -> Self {
|
||||
Self {
|
||||
generic,
|
||||
step: Step::Select,
|
||||
handle: Handle::NULL,
|
||||
underlay: None,
|
||||
picked: None,
|
||||
inverted: false,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, underlay: Underlay) -> CmdResult {
|
||||
CmdResult::UpdateEntityAndFinish {
|
||||
handle: self.handle,
|
||||
entity: EntityType::Underlay(underlay),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_mode(&mut self) -> CmdResult {
|
||||
self.step = Step::Mode;
|
||||
let mode = if self.inverted { INSIDE } else { OUTSIDE };
|
||||
CmdResult::ReportMeasurement(format!("{mode}\n{BOUNDARY}"))
|
||||
}
|
||||
|
||||
fn to_local(u: &Underlay, p: DVec3) -> Vector2 {
|
||||
let (c, s) = (u.rotation.cos(), u.rotation.sin());
|
||||
let dx = p.x - u.insertion_point.x;
|
||||
let dy = p.y - u.insertion_point.y;
|
||||
let sx = if u.x_scale.abs() > 1e-12 { u.x_scale } else { 1.0 };
|
||||
let sy = if u.y_scale.abs() > 1e-12 { u.y_scale } else { 1.0 };
|
||||
Vector2::new((dx * c + dy * s) / sx, (-dx * s + dy * c) / sy)
|
||||
}
|
||||
|
||||
fn apply_boundary(&self, world: &[DVec3]) -> CmdResult {
|
||||
let Some(mut underlay) = self.underlay.clone() else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
underlay.clip_boundary_vertices = world.iter().map(|p| Self::to_local(&underlay, *p)).collect();
|
||||
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
|
||||
underlay.clip_inverted = self.inverted;
|
||||
self.finish(underlay)
|
||||
}
|
||||
|
||||
fn option(&mut self, text: &str) -> CmdResult {
|
||||
let Some(mut underlay) = self.underlay.clone() else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"ON" => {
|
||||
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"OFF" => {
|
||||
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"D" | "DELETE" => {
|
||||
underlay.clip_boundary_vertices.clear();
|
||||
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"" | "N" | "NEW" | "NEW BOUNDARY" => {
|
||||
if underlay.clip_boundary_vertices.is_empty() {
|
||||
self.enter_mode()
|
||||
} else {
|
||||
self.step = Step::DeleteOld;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode(&mut self, text: &str) -> CmdResult {
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "R" | "RECTANGULAR" => {
|
||||
self.step = Step::RectFirst;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"P" | "POLYGONAL" => {
|
||||
self.points.clear();
|
||||
self.step = Step::PolyPoints;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"S" | "SELECT" | "SELECT POLYLINE" => {
|
||||
self.step = Step::Polyline;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"I" | "INVERT" | "INVERT CLIP" => {
|
||||
self.inverted = !self.inverted;
|
||||
self.enter_mode()
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangle(a: DVec3, b: DVec3) -> Vec<DVec3> {
|
||||
vec![
|
||||
a,
|
||||
DVec3::new(b.x, a.y, a.z),
|
||||
b,
|
||||
DVec3::new(a.x, b.y, a.z),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfClipCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.generic { "CLIP" } else { "PDFCLIP" }
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Select if self.generic => "Select Object to clip:".to_string(),
|
||||
Step::Select => "Select PDF to clip:".to_string(),
|
||||
Step::Option => {
|
||||
"Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:".to_string()
|
||||
}
|
||||
Step::DeleteOld => "Delete old boundary? [Yes/No] <Yes>:".to_string(),
|
||||
Step::Mode => {
|
||||
"[Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:".to_string()
|
||||
}
|
||||
Step::RectFirst => "Specify first corner point:".to_string(),
|
||||
Step::RectSecond => "Specify opposite corner point:".to_string(),
|
||||
Step::PolyPoints => match self.points.len() {
|
||||
0 => "Specify first point:".to_string(),
|
||||
1 | 2 => "Specify next point or [Undo]:".to_string(),
|
||||
_ => "Specify next point or [Close/Undo]:".to_string(),
|
||||
},
|
||||
Step::Polyline => "Select polyline:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Option => vec![
|
||||
CmdOption::new("ON", "ON"),
|
||||
CmdOption::new("OFF", "OFF"),
|
||||
CmdOption::new("Delete", "D"),
|
||||
CmdOption::new("New boundary", "N"),
|
||||
],
|
||||
Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
|
||||
Step::Mode => vec![
|
||||
CmdOption::new("Select polyline", "S"),
|
||||
CmdOption::new("Polygonal", "P"),
|
||||
CmdOption::new("Rectangular", "R"),
|
||||
CmdOption::new("Invert clip", "I"),
|
||||
],
|
||||
Step::PolyPoints if self.points.len() >= 3 => {
|
||||
vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")]
|
||||
}
|
||||
Step::PolyPoints if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Option | Step::DeleteOld | Step::Mode => InputKind::SingleToken,
|
||||
_ => InputKind::Point,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
self.step == Step::PolyPoints
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, Step::Select | Step::Polyline)
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
let picked = self.picked.take();
|
||||
match self.step {
|
||||
Step::Select => match picked {
|
||||
Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => {
|
||||
self.handle = handle;
|
||||
self.underlay = Some(u);
|
||||
self.step = Step::Option;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()),
|
||||
},
|
||||
Step::Polyline => match picked {
|
||||
Some(EntityType::LwPolyline(pl)) if pl.vertices.len() >= 3 => {
|
||||
let z = pl.elevation;
|
||||
let world: Vec<DVec3> = pl
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| DVec3::new(v.location.x, v.location.y, z))
|
||||
.collect();
|
||||
self.apply_boundary(&world)
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid object selected.".to_string()),
|
||||
},
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::RectFirst => {
|
||||
self.points = vec![pt];
|
||||
self.step = Step::RectSecond;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::RectSecond => {
|
||||
let first = self.points[0];
|
||||
self.apply_boundary(&Self::rectangle(first, pt))
|
||||
}
|
||||
Step::PolyPoints => {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(match self.step {
|
||||
Step::Option => self.option(text),
|
||||
Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "Y" | "YES" => self.enter_mode(),
|
||||
"N" | "NO" => match self.underlay.clone() {
|
||||
Some(underlay) => self.finish(underlay),
|
||||
None => CmdResult::Cancel,
|
||||
},
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
},
|
||||
Step::Mode => self.mode(text),
|
||||
Step::PolyPoints => match text.trim().to_ascii_uppercase().as_str() {
|
||||
"U" | "UNDO" => {
|
||||
self.points.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"C" | "CLOSE" if self.points.len() >= 3 => {
|
||||
let points = self.points.clone();
|
||||
self.apply_boundary(&points)
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Option => self.option(""),
|
||||
Step::DeleteOld => self.enter_mode(),
|
||||
Step::Mode => self.mode(""),
|
||||
Step::PolyPoints if self.points.len() >= 3 => {
|
||||
let points = self.points.clone();
|
||||
self.apply_boundary(&points)
|
||||
}
|
||||
Step::PolyPoints => CmdResult::NeedPoint,
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let ring: Vec<DVec3> = match self.step {
|
||||
Step::RectSecond => Self::rectangle(self.points[0], pt),
|
||||
Step::PolyPoints if !self.points.is_empty() => {
|
||||
let mut ring = self.points.clone();
|
||||
ring.push(pt);
|
||||
ring
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut points: Vec<[f32; 3]> = ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect();
|
||||
points.push(points[0]);
|
||||
Some(WireModel::solid("pdf_clip_boundary".into(), points, WireModel::CYAN, false))
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFCLIP", "CLIP"]
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// PDFIMPORT — turn the vector content of a PDF underlay into drawing objects.
|
||||
//
|
||||
// Select PDF underlay or [File] <File>:
|
||||
// Specify first corner of area to import or [Polygonal/All/Settings] <All>:
|
||||
// Specify opposite corner:
|
||||
// Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] <Unload>:
|
||||
// Binding PDF file <path>, page <n> ...
|
||||
//
|
||||
// Geometry goes to PDF_Geometry and text to PDF_Text (the PDF has no layers
|
||||
// of its own to use): chains of straight segments become polylines, a closed
|
||||
// four-arc Bézier loop that is a circle becomes a CIRCLE, other curves become
|
||||
// splines, filled areas solid hatches and text runs MTEXT in a "PDF <font>"
|
||||
// text style.
|
||||
|
||||
use acadrust::entities::{
|
||||
AttachmentPoint, BoundaryEdge, BoundaryPath, Circle, Hatch, LwPolyline, MText, PolylineEdge,
|
||||
Spline, Underlay, UnderlayType,
|
||||
};
|
||||
use acadrust::types::{Color, Handle, LineWeight, Vector2, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::scene::model::pdf_vector::{PageVectors, PdfPath, Segment, SubPath};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const GEOMETRY_LAYER: &str = "PDF_Geometry";
|
||||
pub const TEXT_LAYER: &str = "PDF_Text";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImportArea {
|
||||
All,
|
||||
Polygon(Vec<DVec3>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UnderlayMode {
|
||||
Keep,
|
||||
Detach,
|
||||
Unload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfImportRequest {
|
||||
pub underlay: Handle,
|
||||
pub area: ImportArea,
|
||||
pub mode: UnderlayMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Select,
|
||||
Area,
|
||||
Opposite,
|
||||
Polygon,
|
||||
Mode,
|
||||
}
|
||||
|
||||
pub struct PdfImportCommand {
|
||||
step: Step,
|
||||
handle: Handle,
|
||||
picked: Option<EntityType>,
|
||||
points: Vec<DVec3>,
|
||||
area: ImportArea,
|
||||
}
|
||||
|
||||
impl PdfImportCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: Step::Select,
|
||||
handle: Handle::NULL,
|
||||
picked: None,
|
||||
points: Vec::new(),
|
||||
area: ImportArea::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn area_choice(&mut self, text: &str) -> CmdResult {
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "A" | "ALL" => {
|
||||
self.area = ImportArea::All;
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"P" | "POLYGONAL" => {
|
||||
self.points.clear();
|
||||
self.step = Step::Polygon;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"S" | "SETTINGS" => {
|
||||
CmdResult::ReportError("PDF import settings are not available.".to_string())
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_choice(&mut self, text: &str) -> CmdResult {
|
||||
let mode = match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "U" | "UNLOAD" => UnderlayMode::Unload,
|
||||
"K" | "KEEP" => UnderlayMode::Keep,
|
||||
"D" | "DETACH" => UnderlayMode::Detach,
|
||||
_ => return CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
};
|
||||
CmdResult::PdfImport(PdfImportRequest {
|
||||
underlay: self.handle,
|
||||
area: self.area.clone(),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfImportCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"PDFIMPORT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Select => "Select PDF underlay or [File] <File>:".to_string(),
|
||||
Step::Area => {
|
||||
"Specify first corner of area to import or [Polygonal/All/Settings] <All>:".to_string()
|
||||
}
|
||||
Step::Opposite => "Specify opposite corner:".to_string(),
|
||||
Step::Polygon if self.points.is_empty() => "Specify first point:".to_string(),
|
||||
Step::Polygon => "Specify next point or [Undo]:".to_string(),
|
||||
Step::Mode => {
|
||||
"Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] <Unload>:".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Select => vec![CmdOption::new("File", "F")],
|
||||
Step::Area => vec![
|
||||
CmdOption::new("Polygonal", "P"),
|
||||
CmdOption::new("All", "A"),
|
||||
CmdOption::new("Settings", "S"),
|
||||
],
|
||||
Step::Polygon if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")],
|
||||
Step::Mode => vec![
|
||||
CmdOption::new("Keep", "K"),
|
||||
CmdOption::new("Detach", "D"),
|
||||
CmdOption::new("Unload", "U"),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Mode => InputKind::SingleToken,
|
||||
_ => InputKind::Point,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
matches!(self.step, Step::Select | Step::Area | Step::Polygon)
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
self.step == Step::Select
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
match self.picked.take() {
|
||||
Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => {
|
||||
self.handle = handle;
|
||||
self.step = Step::Area;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Area => {
|
||||
self.points = vec![pt];
|
||||
self.step = Step::Opposite;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Opposite => {
|
||||
let a = self.points[0];
|
||||
self.area = ImportArea::Polygon(vec![
|
||||
a,
|
||||
DVec3::new(pt.x, a.y, a.z),
|
||||
pt,
|
||||
DVec3::new(a.x, pt.y, a.z),
|
||||
]);
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Polygon => {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let token = text.trim().to_ascii_uppercase();
|
||||
Some(match self.step {
|
||||
Step::Select if token == "F" || token == "FILE" => {
|
||||
CmdResult::Dispatch("_PDFIMPORTFILE".to_string())
|
||||
}
|
||||
Step::Area => self.area_choice(&token),
|
||||
Step::Polygon if token == "U" || token == "UNDO" => {
|
||||
self.points.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Mode => self.mode_choice(&token),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Select => CmdResult::Dispatch("_PDFIMPORTFILE".to_string()),
|
||||
Step::Area => self.area_choice(""),
|
||||
Step::Polygon if self.points.len() >= 3 => {
|
||||
self.area = ImportArea::Polygon(self.points.clone());
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Polygon => CmdResult::NeedPoint,
|
||||
Step::Mode => self.mode_choice(""),
|
||||
Step::Opposite => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let ring: Vec<DVec3> = match self.step {
|
||||
Step::Opposite => {
|
||||
let a = self.points[0];
|
||||
vec![a, DVec3::new(pt.x, a.y, a.z), pt, DVec3::new(a.x, pt.y, a.z)]
|
||||
}
|
||||
Step::Polygon if !self.points.is_empty() => {
|
||||
let mut ring = self.points.clone();
|
||||
ring.push(pt);
|
||||
ring
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut points: Vec<[f32; 3]> =
|
||||
ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect();
|
||||
points.push(points[0]);
|
||||
Some(WireModel::solid("pdf_import_area".into(), points, WireModel::CYAN, false))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversion ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// What an import adds: objects, and the layers and text styles they use
|
||||
/// (name, colour / font file), created when missing.
|
||||
#[derive(Default)]
|
||||
pub struct ImportResult {
|
||||
pub entities: Vec<EntityType>,
|
||||
pub layers: Vec<(String, Color)>,
|
||||
pub text_styles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A PDF colour as an index colour when it is one of the basic seven,
|
||||
/// otherwise as a true colour.
|
||||
fn color_of(rgb: [u8; 3]) -> Color {
|
||||
match rgb {
|
||||
[255, 0, 0] => Color::Index(1),
|
||||
[255, 255, 0] => Color::Index(2),
|
||||
[0, 255, 0] => Color::Index(3),
|
||||
[0, 255, 255] => Color::Index(4),
|
||||
[0, 0, 255] => Color::Index(5),
|
||||
[255, 0, 255] => Color::Index(6),
|
||||
[0, 0, 0] | [255, 255, 255] => Color::Index(7),
|
||||
[r, g, b] => Color::Rgb { r, g, b },
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke width in points → the nearest standard lineweight.
|
||||
fn lineweight_of(width_pt: f64) -> LineWeight {
|
||||
const STANDARD: [i16; 24] = [
|
||||
0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158,
|
||||
200, 211,
|
||||
];
|
||||
if width_pt <= 0.0 {
|
||||
return LineWeight::ByLayer;
|
||||
}
|
||||
let hundredths = width_pt * 25.4 / 72.0 * 100.0;
|
||||
let best = STANDARD
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|a, b| {
|
||||
(*a as f64 - hundredths)
|
||||
.abs()
|
||||
.total_cmp(&(*b as f64 - hundredths).abs())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
LineWeight::Value(best)
|
||||
}
|
||||
|
||||
fn font_file_for(font: &str) -> String {
|
||||
let f = font.to_ascii_lowercase();
|
||||
if f.contains("times") {
|
||||
"times.ttf"
|
||||
} else if f.contains("courier") {
|
||||
"cour.ttf"
|
||||
} else {
|
||||
"arial.ttf"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn bezier(seg: &Segment, t: f64) -> [f64; 2] {
|
||||
match seg {
|
||||
Segment::Line(a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
|
||||
Segment::Cubic(p0, p1, p2, p3) => {
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
[
|
||||
a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
|
||||
a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre and radius when a closed loop of cubics traces a circle.
|
||||
fn as_circle(sp: &SubPath) -> Option<([f64; 2], f64)> {
|
||||
if !sp.closed || sp.segments.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let cubics: Vec<&Segment> = sp
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|s| matches!(s, Segment::Cubic(..)))
|
||||
.collect();
|
||||
if cubics.len() != sp.segments.len() && sp.segments.len() - cubics.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
let starts: Vec<[f64; 2]> = cubics.iter().map(|s| s.start()).collect();
|
||||
let n = starts.len() as f64;
|
||||
let c = [
|
||||
starts.iter().map(|p| p[0]).sum::<f64>() / n,
|
||||
starts.iter().map(|p| p[1]).sum::<f64>() / n,
|
||||
];
|
||||
let dist = |p: [f64; 2]| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
|
||||
let r = starts.iter().map(|p| dist(*p)).sum::<f64>() / n;
|
||||
if r <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let tol = r * 2e-3;
|
||||
let on_circle = cubics.iter().all(|s| {
|
||||
[0.0, 0.25, 0.5, 0.75].iter().all(|t| (dist(bezier(s, *t)) - r).abs() <= tol)
|
||||
});
|
||||
on_circle.then_some((c, r))
|
||||
}
|
||||
|
||||
fn inside(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
|
||||
let mut odd = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[j]);
|
||||
if (a[1] > p[1]) != (b[1] > p[1])
|
||||
&& p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0]
|
||||
{
|
||||
odd = !odd;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// Whether a path reaches into the area: any sampled point inside it.
|
||||
fn path_in_area(path: &PdfPath, area: Option<&[[f64; 2]]>) -> bool {
|
||||
let Some(area) = area else { return true };
|
||||
path.subpaths.iter().any(|sp| {
|
||||
sp.segments
|
||||
.iter()
|
||||
.any(|s| [0.0, 0.5, 1.0].iter().any(|t| inside(area, bezier(s, *t))))
|
||||
})
|
||||
}
|
||||
|
||||
/// The objects an import of `page` creates for an underlay, with the
|
||||
/// area in world coordinates.
|
||||
pub fn convert(page: &PageVectors, underlay: &Underlay, area: &ImportArea) -> ImportResult {
|
||||
let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(underlay, p);
|
||||
let world2 = |p: [f64; 2]| {
|
||||
let w = world(p);
|
||||
Vector2::new(w[0], w[1])
|
||||
};
|
||||
let z = underlay.insertion_point.z;
|
||||
let scale = underlay.x_scale.abs();
|
||||
// The area is tested in page space, where the page content lives.
|
||||
let area_local: Option<Vec<[f64; 2]>> = match area {
|
||||
ImportArea::All => None,
|
||||
ImportArea::Polygon(points) => Some(
|
||||
points
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (c, s) = (underlay.rotation.cos(), underlay.rotation.sin());
|
||||
let dx = p.x - underlay.insertion_point.x;
|
||||
let dy = p.y - underlay.insertion_point.y;
|
||||
[
|
||||
(dx * c + dy * s) / underlay.x_scale,
|
||||
(-dx * s + dy * c) / underlay.y_scale,
|
||||
]
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
let mut result = ImportResult::default();
|
||||
let mut geometry_color: Option<Color> = None;
|
||||
let mut text_color: Option<Color> = None;
|
||||
// The layer takes the colour of its first object; objects of that
|
||||
// colour then follow the layer.
|
||||
fn push(
|
||||
result: &mut ImportResult,
|
||||
mut entity: EntityType,
|
||||
color: Color,
|
||||
layer: &str,
|
||||
layer_color: &mut Option<Color>,
|
||||
) {
|
||||
let layer_color = *layer_color.get_or_insert(color);
|
||||
let common = entity.common_mut();
|
||||
common.layer = layer.to_string();
|
||||
common.color = if color == layer_color { Color::ByLayer } else { color };
|
||||
result.entities.push(entity);
|
||||
}
|
||||
|
||||
for path in &page.paths {
|
||||
if !path_in_area(path, area_local.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
let (color, weight) = match (path.stroke, path.fill) {
|
||||
(Some((rgb, width)), _) => (color_of(rgb), lineweight_of(width)),
|
||||
(None, Some(rgb)) => (color_of(rgb), LineWeight::ByLayer),
|
||||
(None, None) => continue,
|
||||
};
|
||||
if path.stroke.is_none() {
|
||||
// A filled area: one solid hatch over its loops.
|
||||
let mut hatch = Hatch::solid();
|
||||
for sp in &path.subpaths {
|
||||
let mut ring: Vec<Vector2> = Vec::new();
|
||||
for seg in &sp.segments {
|
||||
let steps = if matches!(seg, Segment::Cubic(..)) { 8 } else { 1 };
|
||||
for k in 0..steps {
|
||||
ring.push(world2(bezier(seg, k as f64 / steps as f64)));
|
||||
}
|
||||
}
|
||||
if ring.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut boundary = BoundaryPath::external();
|
||||
boundary.flags.set_polyline(true);
|
||||
boundary.edges.push(BoundaryEdge::Polyline(PolylineEdge::new(ring, true)));
|
||||
hatch.add_path(boundary);
|
||||
}
|
||||
if hatch.paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
hatch.elevation = z;
|
||||
push(&mut result, EntityType::Hatch(hatch), color, GEOMETRY_LAYER, &mut geometry_color);
|
||||
continue;
|
||||
}
|
||||
for sp in &path.subpaths {
|
||||
let mut entity = if let Some((c, r)) = as_circle(sp) {
|
||||
let w = world(c);
|
||||
EntityType::Circle(Circle::from_center_radius(
|
||||
Vector3::new(w[0], w[1], z),
|
||||
r * scale,
|
||||
))
|
||||
} else if sp.segments.iter().all(|s| matches!(s, Segment::Line(..))) {
|
||||
let mut points: Vec<Vector2> = vec![world2(sp.segments[0].start())];
|
||||
points.extend(sp.segments.iter().map(|s| world2(s.end())));
|
||||
if sp.closed && points.len() > 2 {
|
||||
points.pop();
|
||||
}
|
||||
let mut pl = LwPolyline::from_points(points);
|
||||
pl.is_closed = sp.closed;
|
||||
pl.elevation = z;
|
||||
EntityType::LwPolyline(pl)
|
||||
} else {
|
||||
// Mixed or curved: one cubic B-spline through the Bézier
|
||||
// control points (lines as degree-elevated cubics).
|
||||
let mut spline = Spline::new();
|
||||
spline.degree = 3;
|
||||
let mut controls: Vec<Vector3> = Vec::new();
|
||||
for (i, seg) in sp.segments.iter().enumerate() {
|
||||
let (p0, p1, p2, p3) = match seg {
|
||||
Segment::Cubic(p0, p1, p2, p3) => (*p0, *p1, *p2, *p3),
|
||||
Segment::Line(a, b) => (
|
||||
*a,
|
||||
[a[0] + (b[0] - a[0]) / 3.0, a[1] + (b[1] - a[1]) / 3.0],
|
||||
[a[0] + (b[0] - a[0]) * 2.0 / 3.0, a[1] + (b[1] - a[1]) * 2.0 / 3.0],
|
||||
*b,
|
||||
),
|
||||
};
|
||||
let pts = if i == 0 { vec![p0, p1, p2, p3] } else { vec![p1, p2, p3] };
|
||||
for p in pts {
|
||||
let w = world(p);
|
||||
controls.push(Vector3::new(w[0], w[1], z));
|
||||
}
|
||||
}
|
||||
let spans = sp.segments.len();
|
||||
let mut knots = vec![0.0; 4];
|
||||
for k in 1..spans {
|
||||
knots.extend([k as f64; 3]);
|
||||
}
|
||||
knots.extend([spans as f64; 4]);
|
||||
spline.control_points = controls;
|
||||
spline.weights = Vec::new();
|
||||
spline.knots = knots;
|
||||
spline.flags.closed = sp.closed;
|
||||
EntityType::Spline(spline)
|
||||
};
|
||||
entity.common_mut().line_weight = weight;
|
||||
push(&mut result, entity, color, GEOMETRY_LAYER, &mut geometry_color);
|
||||
}
|
||||
}
|
||||
|
||||
for text in &page.texts {
|
||||
let cap = if text.cap_height > 0.0 { text.cap_height } else { 0.0 };
|
||||
let probe = [text.origin[0] + text.width / 2.0, text.origin[1] + cap / 2.0];
|
||||
if let Some(area) = &area_local {
|
||||
if !inside(area, probe) && !inside(area, text.origin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let style = format!("PDF {}", text.font);
|
||||
if !result.text_styles.iter().any(|(name, _)| *name == style) {
|
||||
result.text_styles.push((style.clone(), font_file_for(&text.font)));
|
||||
}
|
||||
let (dx, dy) = (-text.rotation.sin(), text.rotation.cos());
|
||||
let mid = [text.origin[0] + dx * cap / 2.0, text.origin[1] + dy * cap / 2.0];
|
||||
let w = world(mid);
|
||||
let mut mtext = MText::new();
|
||||
mtext.value = text.text.trim_end().to_string();
|
||||
mtext.insertion_point = Vector3::new(w[0], w[1], z);
|
||||
mtext.height = cap * scale;
|
||||
mtext.rectangle_width = 0.0;
|
||||
mtext.rotation = text.rotation + underlay.rotation;
|
||||
mtext.style = style;
|
||||
mtext.attachment_point = AttachmentPoint::MiddleLeft;
|
||||
push(&mut result, EntityType::MText(mtext), color_of(text.color), TEXT_LAYER, &mut text_color);
|
||||
}
|
||||
|
||||
if let Some(color) = geometry_color {
|
||||
result.layers.push((GEOMETRY_LAYER.to_string(), color));
|
||||
}
|
||||
if let Some(color) = text_color {
|
||||
result.layers.push((TEXT_LAYER.to_string(), color));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFIMPORT"]
|
||||
});
|
||||
Vendored
+2
@@ -105,6 +105,8 @@ pub fn visualization_section(entity: &EntityType) -> Option<PropSection> {
|
||||
| EntityType::Seqend(_)
|
||||
| EntityType::Leader(_)
|
||||
| EntityType::Wipeout(_)
|
||||
// The reference lists no 3D group for an underlay.
|
||||
| EntityType::Underlay(_)
|
||||
| EntityType::Unknown(_)
|
||||
// Non-plotting drawing-view border: never rendered, no properties.
|
||||
| EntityType::ViewBorder(_)
|
||||
|
||||
@@ -611,7 +611,7 @@ fn tessellate_entity_inner(
|
||||
match e {
|
||||
EntityType::Viewport(_) | EntityType::Insert(_) => {}
|
||||
_ => {
|
||||
let ab = entity_aabb(e);
|
||||
let ab = entity_aabb_in(document, e);
|
||||
if ab != WireModel::UNBOUNDED_AABB {
|
||||
if let Some(view) = view_aabb {
|
||||
if cache::block_cache::aabb_disjoint_xy(ab, view) {
|
||||
@@ -736,7 +736,7 @@ fn tessellate_entity_inner(
|
||||
bg_color,
|
||||
false,
|
||||
);
|
||||
let ab = entity_aabb(e);
|
||||
let ab = entity_aabb_in(document, e);
|
||||
for w in &mut wires {
|
||||
set_wire_aabb(w, ab);
|
||||
}
|
||||
@@ -990,7 +990,7 @@ fn tessellate_entity_inner(
|
||||
)
|
||||
.wires;
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
for wire in &mut wires {
|
||||
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
|
||||
set_wire_aabb(wire, aabb);
|
||||
@@ -1002,7 +1002,7 @@ fn tessellate_entity_inner(
|
||||
}
|
||||
|
||||
if let EntityType::Dimension(dim) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
use crate::entities::dimension::DimensionTess;
|
||||
let mut wires = dim.tessellate(
|
||||
document,
|
||||
@@ -1032,7 +1032,7 @@ fn tessellate_entity_inner(
|
||||
}
|
||||
|
||||
if let EntityType::MultiLeader(ml) = e {
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
use crate::entities::multileader::MultiLeaderTess;
|
||||
let mut wires = ml.tessellate(
|
||||
document,
|
||||
@@ -1099,7 +1099,7 @@ fn tessellate_entity_inner(
|
||||
)
|
||||
.wires;
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
for wire in &mut wires {
|
||||
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
|
||||
set_wire_aabb(wire, aabb);
|
||||
@@ -1150,7 +1150,7 @@ fn tessellate_entity_inner(
|
||||
);
|
||||
}
|
||||
if !wires.is_empty() {
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
for wire in &mut wires {
|
||||
wire.aci = aci;
|
||||
if !wire.points.is_empty() || !wire.fill_tris.is_empty() {
|
||||
@@ -1245,7 +1245,7 @@ fn tessellate_entity_inner(
|
||||
return wires;
|
||||
}
|
||||
|
||||
let aabb = entity_aabb(e);
|
||||
let aabb = entity_aabb_in(document, e);
|
||||
|
||||
// TEXT / MTEXT / ATTDEF / ATTRIB / Tolerance all render as SDF glyph quads
|
||||
// (crisp at every zoom), so there is no text LOD ladder — they fall through
|
||||
@@ -1753,6 +1753,30 @@ pub(crate) fn entity_bounds(e: &acadrust::EntityType) -> ([f64; 3], [f64; 3]) {
|
||||
)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the document, for entities whose extent is not
|
||||
/// stored on the entity: an underlay spans its page (or its clip).
|
||||
pub(crate) fn entity_bounds_in(
|
||||
document: &acadrust::CadDocument,
|
||||
e: &acadrust::EntityType,
|
||||
) -> ([f64; 3], [f64; 3]) {
|
||||
if let acadrust::EntityType::Underlay(underlay) = e {
|
||||
if let Some(bounds) = crate::entities::underlay::world_bounds(underlay, document) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
entity_bounds(e)
|
||||
}
|
||||
|
||||
/// [`entity_aabb`] with the document (see [`entity_bounds_in`]).
|
||||
pub(crate) fn entity_aabb_in(document: &acadrust::CadDocument, e: &acadrust::EntityType) -> [f32; 4] {
|
||||
if let acadrust::EntityType::Underlay(underlay) = e {
|
||||
if let Some((min, max)) = crate::entities::underlay::world_bounds(underlay, document) {
|
||||
return [min[0] as f32, min[1] as f32, max[0] as f32, max[1] as f32];
|
||||
}
|
||||
}
|
||||
entity_aabb(e)
|
||||
}
|
||||
|
||||
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
|
||||
let (min, max) = entity_bounds(e);
|
||||
let min_x = min[0] as f32;
|
||||
|
||||
+21
-1
@@ -673,6 +673,25 @@ impl Scene {
|
||||
/// or drop them all if the handle is now absent. Mirrors the reseed block in
|
||||
/// [`Scene::update_entity`]; used by delta-undo when it re-applies an
|
||||
/// entity's before / after image so the fills and meshes follow.
|
||||
/// Rebuild every underlay's raster and wire, after a setting they draw
|
||||
/// or snap by (PDFOSNAP / UOSNAP) changed.
|
||||
pub(crate) fn reseed_underlays(&mut self) {
|
||||
let handles: Vec<Handle> = self
|
||||
.document
|
||||
.entities()
|
||||
.filter(|entity| matches!(entity, EntityType::Underlay(_)))
|
||||
.map(|entity| entity.common().handle)
|
||||
.collect();
|
||||
for handle in &handles {
|
||||
self.reseed_derived_caches(*handle);
|
||||
}
|
||||
let changes: Vec<_> = handles
|
||||
.iter()
|
||||
.map(|handle| (*handle, crate::scene::ChangeKind::Modified))
|
||||
.collect();
|
||||
self.bump_entities(&changes);
|
||||
}
|
||||
|
||||
pub(crate) fn reseed_derived_caches(&mut self, handle: Handle) {
|
||||
let (hatch_seed, image_seed) = match self.document.get_entity(handle) {
|
||||
None => (None, None),
|
||||
@@ -2387,9 +2406,10 @@ impl Scene {
|
||||
match entity {
|
||||
EntityType::RasterImage(img) => ImageModel::from_raster_image(img),
|
||||
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole),
|
||||
EntityType::Underlay(u) if self.unloaded_underlay_definitions.contains(&u.definition_handle) => None,
|
||||
EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) {
|
||||
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
|
||||
ImageModel::from_underlay(u, def)
|
||||
ImageModel::from_underlay(u, def, self.bg_color)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
|
||||
+26
-1
@@ -1268,7 +1268,7 @@ fn build_derived_caches_impl(
|
||||
EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)),
|
||||
EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) {
|
||||
Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => {
|
||||
ImageModel::from_underlay(u, def).map(|m| (handle, m))
|
||||
ImageModel::from_underlay(u, def, LOAD_BG).map(|m| (handle, m))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
@@ -2250,6 +2250,9 @@ pub struct Scene {
|
||||
pub bg_color: [f32; 4],
|
||||
/// Custom paper-space background fill color for Wipeout entities.
|
||||
pub paper_bg_color: [f32; 4],
|
||||
/// PDF definitions unloaded in this session (PDFIMPORT Unload): their
|
||||
/// underlays show no page content.
|
||||
pub unloaded_underlay_definitions: std::collections::HashSet<Handle>,
|
||||
/// Dense model-space cluster half-span used for viewport recovery.
|
||||
pub local_extent_max: f32,
|
||||
/// Dense model-space cluster median used for viewport recovery.
|
||||
@@ -2659,6 +2662,7 @@ impl Scene {
|
||||
active_viewport: None,
|
||||
bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0],
|
||||
paper_bg_color: [1.0, 1.0, 1.0, 1.0],
|
||||
unloaded_underlay_definitions: std::collections::HashSet::new(),
|
||||
local_extent_max: 1e9,
|
||||
local_center: [0.0, 0.0],
|
||||
annotation_scale: 1.0,
|
||||
@@ -3396,6 +3400,27 @@ impl Scene {
|
||||
self.refresh_dynamic_dimension_texts();
|
||||
self.sync_native_parametric_graph();
|
||||
}
|
||||
// A modified image-bearing entity redraws its raster: an underlay's
|
||||
// display adjustments and clip live on the entity, not the image.
|
||||
for (handle, kind) in &changes {
|
||||
if !matches!(kind, ChangeKind::Modified) {
|
||||
continue;
|
||||
}
|
||||
let seed = match self.document.get_entity(*handle) {
|
||||
Some(
|
||||
entity @ (EntityType::RasterImage(_)
|
||||
| EntityType::Ole2Frame(_)
|
||||
| EntityType::Underlay(_)),
|
||||
) => Some(self.image_seed_for(entity)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(seed) = seed {
|
||||
self.images.remove(handle);
|
||||
if let Some(model) = seed {
|
||||
self.images.insert(*handle, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
self.refresh_dependency_index_for_changes(&changes);
|
||||
}
|
||||
|
||||
@@ -186,16 +186,22 @@ impl ImageModel {
|
||||
impl ImageModel {
|
||||
/// Build an ImageModel from a PDF UNDERLAY + its definition object.
|
||||
///
|
||||
/// The page rasterises through `pdf_raster` (system pdftoppm, cached);
|
||||
/// the world quad sizes from the page's physical size — 1 underlay unit =
|
||||
/// 1 PDF inch — times the entity's scale, placed at the insertion with
|
||||
/// the entity's rotation. `None` when the underlay is off, non-PDF, or
|
||||
/// the page can't be rendered (caller keeps the outline placeholder).
|
||||
/// The page rasterises through `pdf_raster` with no page background, so
|
||||
/// only the drawn content covers the drawing; contrast, monochrome and
|
||||
/// the background adjustment recolour that raster and fade lowers its
|
||||
/// opacity. The world quad is the page size in inches (1 drawing unit per
|
||||
/// page inch) times the entity scale, placed at the insertion with the
|
||||
/// entity rotation. A clip boundary (on, 2+ vertices, in underlay units)
|
||||
/// limits the drawn area to its inside, or to the page outside it when
|
||||
/// inverted. `None` when the underlay is off, non-PDF, or the page can't
|
||||
/// be rendered (caller keeps the outline placeholder).
|
||||
pub fn from_underlay(
|
||||
u: &acadrust::entities::Underlay,
|
||||
def: &acadrust::entities::UnderlayDefinition,
|
||||
background: [f32; 4],
|
||||
) -> Option<Self> {
|
||||
use acadrust::entities::{UnderlayDisplayFlags, UnderlayType};
|
||||
use super::pdf_raster::{self, PageAdjust};
|
||||
if !u.flags.contains(UnderlayDisplayFlags::ON) {
|
||||
return None;
|
||||
}
|
||||
@@ -207,12 +213,25 @@ impl ImageModel {
|
||||
} else {
|
||||
def.page_name.trim()
|
||||
};
|
||||
let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?;
|
||||
let (page_w, page_h) = pdf_raster::page_size_inches(&def.file_path, page)?;
|
||||
let raster = pdf_raster::rasterize_page_display(&def.file_path, page)?;
|
||||
let bg_lum = 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2];
|
||||
let pixels = pdf_raster::adjusted_pixels(
|
||||
&def.file_path,
|
||||
page,
|
||||
&raster,
|
||||
PageAdjust {
|
||||
contrast: u.contrast.min(100),
|
||||
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
|
||||
adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND),
|
||||
dark_background: bg_lum <= 0.5,
|
||||
},
|
||||
);
|
||||
|
||||
// Page size in drawing units (1 unit per PDF inch), entity scale applied.
|
||||
let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale;
|
||||
let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale;
|
||||
if w_du <= 0.0 || h_du <= 0.0 {
|
||||
let w_du = page_w * u.x_scale;
|
||||
let h_du = page_h * u.y_scale;
|
||||
if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (c, s) = (u.rotation.cos(), u.rotation.sin());
|
||||
@@ -238,35 +257,7 @@ impl ImageModel {
|
||||
];
|
||||
let corners_low = [[oxl, oyl, ozl]; 4];
|
||||
|
||||
// Visible region: the clip polygon (vertices in underlay units,
|
||||
// scaled like the quad) fan-triangulated, or the full page. Inverted
|
||||
// clips fall back to the full page (rare; better whole than hidden).
|
||||
let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING)
|
||||
&& !u.clip_inverted
|
||||
&& u.clip_boundary_vertices.len() >= 3;
|
||||
let tris_uv: Vec<[f64; 2]> = if clipping {
|
||||
let pts: Vec<[f64; 2]> = u
|
||||
.clip_boundary_vertices
|
||||
.iter()
|
||||
.map(|p| [p.x * u.x_scale / w_du, p.y * u.y_scale / h_du])
|
||||
.collect();
|
||||
let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3);
|
||||
for i in 1..pts.len() - 1 {
|
||||
tris.push(pts[0]);
|
||||
tris.push(pts[i]);
|
||||
tris.push(pts[i + 1]);
|
||||
}
|
||||
tris
|
||||
} else {
|
||||
vec![
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
[0.0, 1.0],
|
||||
]
|
||||
};
|
||||
let tris_uv = underlay_visible_uv(u, page_w, page_h);
|
||||
let verts: Vec<ImageQuadVertex> = tris_uv
|
||||
.iter()
|
||||
.map(|&[fu, fv]| {
|
||||
@@ -282,10 +273,10 @@ impl ImageModel {
|
||||
Some(Self {
|
||||
render_instance: None,
|
||||
file_path: def.file_path.clone(),
|
||||
pixels: raster.pixels.clone(),
|
||||
pixels,
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
opacity: 1.0 - u.fade as f32 / 100.0,
|
||||
opacity: 1.0 - u.fade.min(100) as f32 / 100.0,
|
||||
corners,
|
||||
corners_low,
|
||||
draw_depth: 0.0,
|
||||
@@ -294,6 +285,29 @@ impl ImageModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible region of an underlay page as triangles in page UV (0..1, y up):
|
||||
/// the whole page, the clip polygon, or — for an inverted clip — the page
|
||||
/// with the polygon cut out.
|
||||
fn underlay_visible_uv(u: &acadrust::entities::Underlay, page_w: f64, page_h: f64) -> Vec<[f64; 2]> {
|
||||
use acadrust::entities::UnderlayDisplayFlags;
|
||||
let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
let whole = || vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
let clip = crate::entities::underlay::clip_polygon_local(u);
|
||||
if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 {
|
||||
return whole();
|
||||
}
|
||||
let ring: Vec<[f64; 2]> = clip.iter().map(|p| [p[0] / page_w, p[1] / page_h]).collect();
|
||||
let (points, triangles) = if u.clip_inverted {
|
||||
cadkernel::geom2d::triangulate(&page, &[ring])
|
||||
} else {
|
||||
cadkernel::geom2d::triangulate(&ring, &[])
|
||||
};
|
||||
triangles
|
||||
.into_iter()
|
||||
.flat_map(|t| t.map(|i| points[i]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
/// Build an ImageModel from an OLE2FRAME's embedded presentation.
|
||||
/// The blob's compound file is parsed for the native raster or the cached
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod instance_model;
|
||||
pub mod material_model;
|
||||
pub mod ole_pres;
|
||||
pub mod pdf_raster;
|
||||
pub mod pdf_vector;
|
||||
pub mod mesh_model;
|
||||
pub mod solid_model;
|
||||
pub mod solid_history;
|
||||
|
||||
+132
-10
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||
use hayro::{RenderCache, RenderSettings};
|
||||
|
||||
/// One rasterised PDF page. `dpi` ties its pixel size to its physical size.
|
||||
@@ -18,7 +18,8 @@ pub struct PdfPage {
|
||||
|
||||
const RASTER_DPI: f32 = 150.0;
|
||||
|
||||
type PageKey = (String, String, u32);
|
||||
/// Source path, page, DPI bits and whether the page background is transparent.
|
||||
type PageKey = (String, String, u32, bool);
|
||||
|
||||
fn page_cache() -> &'static Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PageKey, Option<Arc<PdfPage>>>>> = OnceLock::new();
|
||||
@@ -40,7 +41,11 @@ pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.retain(|(cached_path, _, _), _| cached_path != path);
|
||||
.retain(|(cached_path, _, _, _), _| cached_path != path);
|
||||
adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.retain(|key, _| key.0 != path);
|
||||
}
|
||||
|
||||
/// Rasterise a 1-based PDF page, memoised by source path and page name.
|
||||
@@ -51,7 +56,18 @@ pub fn rasterize_page(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source
|
||||
/// path, page and DPI (print quality differs from the on-screen 150 DPI).
|
||||
pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string(), dpi.to_bits());
|
||||
rasterize_cached(path, page, dpi, false)
|
||||
}
|
||||
|
||||
/// Rasterise a page for display: no page background (transparent pixels
|
||||
/// outside the drawn content) and straight, not premultiplied, alpha — the
|
||||
/// page is drawn over the drawing background like its vector content.
|
||||
pub fn rasterize_page_display(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
rasterize_cached(path, page, RASTER_DPI, true)
|
||||
}
|
||||
|
||||
fn rasterize_cached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
|
||||
let key = (path.to_string(), page.to_string(), dpi.to_bits(), transparent);
|
||||
if let Some(hit) = page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -61,7 +77,7 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
|
||||
return hit;
|
||||
}
|
||||
|
||||
let built = rasterize_uncached(path, page, dpi);
|
||||
let built = rasterize_uncached(path, page, dpi, transparent);
|
||||
page_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
@@ -73,10 +89,10 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option<Arc<Pdf
|
||||
/// print job, whose 300 DPI page would otherwise stay cached for the whole
|
||||
/// session (tens of megabytes per sheet).
|
||||
pub fn rasterize_page_at_dpi_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
rasterize_uncached(path, page, dpi)
|
||||
rasterize_uncached(path, page, dpi, false)
|
||||
}
|
||||
|
||||
fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>> {
|
||||
fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option<Arc<PdfPage>> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||
@@ -89,21 +105,127 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option<Arc<PdfPage>>
|
||||
&RenderSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let width = u32::from(pixmap.width());
|
||||
let height = u32::from(pixmap.height());
|
||||
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||||
if transparent {
|
||||
unpremultiply(&mut pixels);
|
||||
}
|
||||
Some(Arc::new(PdfPage {
|
||||
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
|
||||
pixels: Arc::new(pixels),
|
||||
width,
|
||||
height,
|
||||
dpi,
|
||||
}))
|
||||
}
|
||||
|
||||
fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
fn unpremultiply(pixels: &mut [u8]) {
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of pages in a PDF source.
|
||||
pub fn page_count(path: &str) -> Option<usize> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
Some(pdf.pages().len())
|
||||
}
|
||||
|
||||
/// Size of a 1-based page in inches (its rendered width and height at 72
|
||||
/// points per inch). An underlay is one drawing unit per page inch.
|
||||
pub fn page_size_inches(path: &str, page: &str) -> Option<(f64, f64)> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().ok()?;
|
||||
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
Some((w as f64 / 72.0, h as f64 / 72.0))
|
||||
}
|
||||
|
||||
/// Display adjustments of an underlay page, applied to its display raster.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct PageAdjust {
|
||||
/// 0..=100; 100 keeps the colours, lower values pull them to mid grey.
|
||||
pub contrast: u8,
|
||||
pub monochrome: bool,
|
||||
/// Lighten dark, unsaturated content on a dark background (black lines
|
||||
/// and text become white), keeping coloured content.
|
||||
pub adjust_for_background: bool,
|
||||
pub dark_background: bool,
|
||||
}
|
||||
|
||||
type AdjustKey = (String, String, PageAdjust);
|
||||
|
||||
fn adjusted_cache() -> &'static Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<AdjustKey, Arc<Vec<u8>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The display raster with the underlay's contrast / monochrome / background
|
||||
/// adjustments applied, memoised per source, page and adjustment.
|
||||
pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc<Vec<u8>> {
|
||||
let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background;
|
||||
if identity {
|
||||
return raster.pixels.clone();
|
||||
}
|
||||
let key = (path.to_string(), page.to_string(), adjust);
|
||||
if let Some(hit) = adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return hit;
|
||||
}
|
||||
let mut pixels = raster.pixels.as_ref().clone();
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
if px[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0];
|
||||
if adjust.monochrome {
|
||||
let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||||
rgb = [l; 3];
|
||||
}
|
||||
if adjust.adjust_for_background {
|
||||
let max = rgb[0].max(rgb[1]).max(rgb[2]);
|
||||
let min = rgb[0].min(rgb[1]).min(rgb[2]);
|
||||
let saturation = if max > 0.0 { (max - min) / max } else { 0.0 };
|
||||
let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||||
// ponytail: greyscale inversion of low-saturation content only;
|
||||
// hue-preserving remap if coloured content must adapt too.
|
||||
let clash = if adjust.dark_background { l < 0.5 } else { l > 0.5 };
|
||||
if saturation < 0.25 && clash {
|
||||
rgb = rgb.map(|c| 1.0 - c);
|
||||
}
|
||||
}
|
||||
if adjust.contrast < 100 {
|
||||
let k = adjust.contrast as f32 / 100.0;
|
||||
rgb = rgb.map(|c| 0.5 + (c - 0.5) * k);
|
||||
}
|
||||
for (dst, c) in px[..3].iter_mut().zip(rgb) {
|
||||
*dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
let pixels = Arc::new(pixels);
|
||||
adjusted_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(key, pixels.clone());
|
||||
pixels
|
||||
}
|
||||
|
||||
pub(crate) fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
if let Some(bytes) = source_cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Vector content of a PDF page — paths and text runs in page inches (y up,
|
||||
//! origin at the page's lower-left corner) — read through the PDF
|
||||
//! interpreter. Drives snapping to an underlay's geometry and PDFIMPORT.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::util::TransformExt;
|
||||
use hayro::hayro_interpret::{
|
||||
interpret_page, BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache,
|
||||
InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
};
|
||||
use hayro::hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro::hayro_syntax::object::Name;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape};
|
||||
|
||||
/// One segment of a path, in page inches.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Segment {
|
||||
Line([f64; 2], [f64; 2]),
|
||||
Cubic([f64; 2], [f64; 2], [f64; 2], [f64; 2]),
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn start(&self) -> [f64; 2] {
|
||||
match self {
|
||||
Segment::Line(a, _) | Segment::Cubic(a, ..) => *a,
|
||||
}
|
||||
}
|
||||
pub fn end(&self) -> [f64; 2] {
|
||||
match self {
|
||||
Segment::Line(_, b) | Segment::Cubic(_, _, _, b) => *b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SubPath {
|
||||
pub segments: Vec<Segment>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfPath {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
/// Stroke colour (RGB) and width in points, for stroked paths.
|
||||
pub stroke: Option<([u8; 3], f64)>,
|
||||
/// Fill colour (RGB), for filled paths.
|
||||
pub fill: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// A run of glyphs on one baseline in one font and size.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfText {
|
||||
pub text: String,
|
||||
/// Base font name without a subset prefix ("Helvetica").
|
||||
pub font: String,
|
||||
/// Baseline start, page inches.
|
||||
pub origin: [f64; 2],
|
||||
/// Height of the capital letters, inches.
|
||||
pub cap_height: f64,
|
||||
/// Advance width of the run, inches.
|
||||
pub width: f64,
|
||||
/// Baseline direction, radians.
|
||||
pub rotation: f64,
|
||||
pub color: [u8; 3],
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct PageVectors {
|
||||
pub paths: Vec<PdfPath>,
|
||||
pub texts: Vec<PdfText>,
|
||||
}
|
||||
|
||||
type Key = (String, String);
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<Key, Option<Arc<PageVectors>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Option<Arc<PageVectors>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Paths and text of a 1-based page, memoised per source and page.
|
||||
pub fn page_vectors(path: &str, page: &str) -> Option<Arc<PageVectors>> {
|
||||
let key = (path.to_string(), page.to_string());
|
||||
if let Some(hit) = cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return hit;
|
||||
}
|
||||
let built = read_page(path, page).map(Arc::new);
|
||||
cache()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(key, built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
fn read_page(path: &str, page: &str) -> Option<PageVectors> {
|
||||
let bytes = super::pdf_raster::source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().ok()?;
|
||||
let page = pdf.pages().get(page_no.checked_sub(1)?)?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
// Page points with y up → inches.
|
||||
let initial = Affine::scale(1.0 / 72.0) * page.initial_transform(false).to_kurbo();
|
||||
let cache = InterpreterCache::new();
|
||||
let mut context = Context::new(
|
||||
initial,
|
||||
Rect::new(0.0, 0.0, w as f64, h as f64),
|
||||
&cache,
|
||||
page.xref(),
|
||||
InterpreterSettings::default(),
|
||||
);
|
||||
let mut device = Collector::default();
|
||||
interpret_page(page, &mut context, &mut device);
|
||||
device.finish_text();
|
||||
|
||||
// Base font names in the order the content stream selects them; glyph
|
||||
// fonts are matched to them in their order of first use.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut ops = page.typed_operations();
|
||||
while let Some(op) = ops.next() {
|
||||
if let TypedInstruction::TextFont(font) = op {
|
||||
let name = page
|
||||
.resources()
|
||||
.get_font(font.0)
|
||||
.and_then(|dict| dict.get::<Name>(b"BaseFont"))
|
||||
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
||||
.unwrap_or_default();
|
||||
let name = name.split_once('+').map(|(_, rest)| rest.to_string()).unwrap_or(name);
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut texts = device.texts;
|
||||
for text in &mut texts {
|
||||
let index = device.font_order.iter().position(|key| *key == text.font_key);
|
||||
text.text.font = index
|
||||
.and_then(|i| names.get(i).cloned())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "Text".to_string());
|
||||
}
|
||||
Some(PageVectors {
|
||||
paths: device.paths,
|
||||
texts: texts.into_iter().map(|t| t.text).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
struct PendingText {
|
||||
text: PdfText,
|
||||
font_key: u128,
|
||||
/// Baseline end, inches, for joining the next glyph.
|
||||
end: [f64; 2],
|
||||
em: f64,
|
||||
/// Tops of the capital letters seen so far, inches.
|
||||
caps: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Collector {
|
||||
paths: Vec<PdfPath>,
|
||||
texts: Vec<PendingText>,
|
||||
current: Option<PendingText>,
|
||||
font_order: Vec<u128>,
|
||||
}
|
||||
|
||||
impl Collector {
|
||||
fn finish_text(&mut self) {
|
||||
if let Some(text) = self.current.take() {
|
||||
if !text.text.text.trim().is_empty() {
|
||||
self.texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(paint: &Paint<'_>) -> [u8; 3] {
|
||||
match paint {
|
||||
Paint::Color(color) => {
|
||||
let c = color.to_rgba().to_rgba8();
|
||||
[c[0], c[1], c[2]]
|
||||
}
|
||||
Paint::Pattern(_) => [0, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
fn pt(p: Point) -> [f64; 2] {
|
||||
[p.x, p.y]
|
||||
}
|
||||
|
||||
fn subpaths(path: &BezPath, transform: Affine) -> Vec<SubPath> {
|
||||
let mut out: Vec<SubPath> = Vec::new();
|
||||
let mut start = Point::ZERO;
|
||||
let mut last = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
let p = transform * p;
|
||||
out.push(SubPath { segments: Vec::new(), closed: false });
|
||||
start = p;
|
||||
last = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let p = transform * p;
|
||||
if let Some(sp) = out.last_mut() {
|
||||
sp.segments.push(Segment::Line(pt(last), pt(p)));
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
PathEl::QuadTo(c, p) => {
|
||||
let (c, p) = (transform * c, transform * p);
|
||||
let c1 = last + (c - last) * (2.0 / 3.0);
|
||||
let c2 = p + (c - p) * (2.0 / 3.0);
|
||||
if let Some(sp) = out.last_mut() {
|
||||
sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p)));
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
PathEl::CurveTo(c1, c2, p) => {
|
||||
let (c1, c2, p) = (transform * c1, transform * c2, transform * p);
|
||||
if let Some(sp) = out.last_mut() {
|
||||
sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p)));
|
||||
}
|
||||
last = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(sp) = out.last_mut() {
|
||||
if (last - start).hypot() > 1e-9 {
|
||||
sp.segments.push(Segment::Line(pt(last), pt(start)));
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
last = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.retain(|sp| !sp.segments.is_empty());
|
||||
// A path drawn back to its start without a close operator is closed.
|
||||
for sp in &mut out {
|
||||
let (first, last) = (sp.segments[0].start(), sp.segments[sp.segments.len() - 1].end());
|
||||
if !sp.closed && (first[0] - last[0]).hypot(first[1] - last[1]) < 1e-9 {
|
||||
sp.closed = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Collector {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, mode: &PathDrawMode) {
|
||||
self.finish_text();
|
||||
let subpaths = subpaths(path, transform);
|
||||
if subpaths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (stroke, fill) = match mode {
|
||||
PathDrawMode::Stroke(props) => {
|
||||
// Width in points: the user-space width through the transform
|
||||
// (which is in inches), back to points.
|
||||
let scale = transform.determinant().abs().sqrt() * 72.0;
|
||||
(Some((rgb(paint), props.line_width as f64 * scale)), None)
|
||||
}
|
||||
PathDrawMode::Fill(_) => (None, Some(rgb(paint))),
|
||||
};
|
||||
self.paths.push(PdfPath { subpaths, stroke, fill });
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(
|
||||
&mut self,
|
||||
glyph: &Glyph<'a>,
|
||||
transform: Affine,
|
||||
glyph_transform: Affine,
|
||||
paint: &Paint<'a>,
|
||||
_: &GlyphDrawMode,
|
||||
) {
|
||||
let Glyph::Outline(outline) = glyph else {
|
||||
return;
|
||||
};
|
||||
let full = transform * glyph_transform;
|
||||
let origin = full * Point::ZERO;
|
||||
// Outlines are in 1000 units per em.
|
||||
let em_vec = full * Point::new(0.0, 1000.0) - origin;
|
||||
let em = em_vec.hypot();
|
||||
if em <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let advance = outline.advance_width().unwrap_or(0.0) as f64;
|
||||
let adv_vec = full * Point::new(advance, 0.0) - origin;
|
||||
let run_dir = full * Point::new(1000.0, 0.0) - origin;
|
||||
let rotation = run_dir.y.atan2(run_dir.x);
|
||||
let text = outline
|
||||
.as_unicode()
|
||||
.map(|s| match s {
|
||||
hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(),
|
||||
hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let key = outline.font_cache_key();
|
||||
if !self.font_order.contains(&key) {
|
||||
self.font_order.push(key);
|
||||
}
|
||||
let bounds = outline.outline().bounding_box();
|
||||
let glyph_top = text
|
||||
.chars()
|
||||
.any(|c| c.is_uppercase())
|
||||
.then(|| bounds.y1 / 1000.0 * em);
|
||||
let origin = pt(origin);
|
||||
let joins = self.current.as_ref().is_some_and(|run| {
|
||||
run.font_key == key
|
||||
&& (run.em - em).abs() < 1e-6
|
||||
&& (run.text.rotation - rotation).abs() < 1e-6
|
||||
&& ((run.end[0] - origin[0]).powi(2) + (run.end[1] - origin[1]).powi(2)).sqrt()
|
||||
< 0.3 * em
|
||||
});
|
||||
if !joins {
|
||||
self.finish_text();
|
||||
self.current = Some(PendingText {
|
||||
text: PdfText {
|
||||
text: String::new(),
|
||||
font: String::new(),
|
||||
origin,
|
||||
cap_height: 0.0,
|
||||
width: 0.0,
|
||||
rotation,
|
||||
color: rgb(paint),
|
||||
},
|
||||
font_key: key,
|
||||
end: origin,
|
||||
em,
|
||||
caps: Vec::new(),
|
||||
});
|
||||
}
|
||||
if let Some(run) = self.current.as_mut() {
|
||||
run.text.text.push_str(&text);
|
||||
if let Some(top) = glyph_top {
|
||||
run.caps.push(top);
|
||||
// Capital height: the median capital top, so round letters'
|
||||
// overshoot (O, G) does not lift it.
|
||||
let mut caps = run.caps.clone();
|
||||
caps.sort_by(f64::total_cmp);
|
||||
run.text.cap_height = caps[caps.len() / 2];
|
||||
}
|
||||
run.end = [origin[0] + adv_vec.x, origin[1] + adv_vec.y];
|
||||
run.text.width = ((run.end[0] - run.text.origin[0]).powi(2)
|
||||
+ (run.end[1] - run.text.origin[1]).powi(2))
|
||||
.sqrt();
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
// ── Snapping ────────────────────────────────────────────────────────────────
|
||||
|
||||
static PDF_OSNAP: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// PDFOSNAP: whether object snaps find the geometry inside PDF underlays.
|
||||
pub fn pdf_osnap() -> bool {
|
||||
PDF_OSNAP.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_pdf_osnap(on: bool) {
|
||||
PDF_OSNAP.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Most snap points one underlay contributes.
|
||||
// ponytail: a flat cap; a spatial index over the page segments if large
|
||||
// drawings need every point.
|
||||
const MAX_SNAP_POINTS: usize = 20_000;
|
||||
|
||||
/// Endpoint and midpoint snaps of the underlay's PDF geometry, in world
|
||||
/// space, when PDFOSNAP is on and the page is shown.
|
||||
pub fn underlay_snap_points(
|
||||
u: &acadrust::entities::Underlay,
|
||||
document: &acadrust::CadDocument,
|
||||
) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> {
|
||||
use crate::scene::model::wire_model::SnapHint;
|
||||
if !pdf_osnap() || !u.flags.contains(acadrust::entities::UnderlayDisplayFlags::ON) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(def) = crate::entities::underlay::definition(u, document) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(vectors) = page_vectors(&def.file_path, crate::entities::underlay::page_of(def)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let world = |p: [f64; 2]| {
|
||||
let w = crate::entities::underlay::local_to_world(u, p);
|
||||
glam::DVec3::new(w[0], w[1], w[2])
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
'paths: for path in &vectors.paths {
|
||||
for sp in &path.subpaths {
|
||||
for seg in &sp.segments {
|
||||
if out.len() >= MAX_SNAP_POINTS {
|
||||
break 'paths;
|
||||
}
|
||||
out.push((world(seg.start()), SnapHint::Endpoint));
|
||||
out.push((world(seg.end()), SnapHint::Endpoint));
|
||||
if let Segment::Line(a, b) = seg {
|
||||
out.push((
|
||||
world([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]),
|
||||
SnapHint::Midpoint,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user