Compare commits

..

3 Commits

Author SHA1 Message Date
Brennen Herbruck 30a306b376 Loft: the parallel-planes check never fails a loft that builds with chord-length parameters
`SectionPlanes::of` asks only whether each section face is a plane, so it reads the
surface's kind as stored (`kind()`) instead of building a NURB for it
(`kind_with_nurb`), which could fail. And an error from the check or from the
plane-distance rebuild now keeps the chord-length skins, as upstream builds them; only
cancellation and unported calls still stop the loft. A loft that builds on main builds
the same here whatever the check meets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:11:47 -05:00
Brennen Herbruck a78e36bd9e Loft: plane-distance parameters only where the chord-length skin leaves its end planes
Review of the previous commit: plane-distance parameters by default reshape lofts that
build correctly today (a waist r 20/10/20 at z 0/5/40 pinches through its axis; another
four-section loft grows 29%), and the parallel test is a hard cutoff.

The skin now takes upstream's chord-length parameters as before, and checks that skin's
control points against the slab between the first and last section planes (within the
B-rep's tolerance; by the convex hull the skin is in the slab when they are). Only when
one lies outside, on parallel sections in order, is every layer skinned again at the
planes' distances, where the height is linear in the parameter and the skin stays in the
slab exactly. Every loft whose chord-length skin stays between its end planes rebuilds bit
for bit; the carved top, whose control points fall 0.24 below its first plane, is still
fixed.

The boolean.api_brep golden moved by the previous commit is back to main's (no golden
changes: 283 units unchanged). New test: the waist and the four-section loft keep the
volumes measured with main's skin (16791.357595192487 and 272283.3927004977).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:43:47 -05:00
Brennen Herbruck 1982f7dfa3 Loft: sections on parallel planes are skinned at their planes' distances, so the loft stays between its end planes
A loft through three or more sections on parallel planes took chord-length parameters
across the sections, from the whole motion of their control points. Where the sections
move far sideways for a small step up (a carved top's first steps: 10 to 24 mm sideways
for 0.6 mm up), the cubic interpolant of the height overshot: the side dipped below the
first section's plane and crossed its own start cap. Joining such a loft onto the body
whose face it starts on then failed ("the mapped B-rep does not validate: outer loop is
not an edge loop", or "the union would delete face ..."), or came out wrong.

The skin now takes each section's parameter as its plane's distance from the first over
the last's. Every section's height is then one linear function of the parameter, which
the interpolation reproduces exactly: the side faces' control points lie between the end
planes, so by the convex hull the loft does too. Sections that are not all parallel, or
not in order along the normal, keep the chord-length parameters.

Results that change: lofts through three or more parallel sections whose spacing differs
from their chord lengths take a slightly different shape between the sections (the same
sections, interpolated at different parameters). One golden moves:
boolean.api_brep issue1036_coincident_skins_intersection_measured, two degree-1 skins
whose ruled surfaces are the same point set under any parameters; its volume moves
26136.268243625735 -> 26136.26817288386 (2.7e-9 relative, the integrator's 1e-6
accuracy) and one face area 3788.658561467813 -> 3788.6586869951957 (3.3e-8, against
1e-4).

Tests: solidcore tests/skin_parallel_sections.rs (control points within the slab; union
with a slab under the first section adds the volumes; both fail without the change, the
first at a control point 0.24 below the first plane, the second with a negative union
volume) and the app regression examples/regressions/loft_join_carved_top_on_slab.json
(the join's volume is the slab's plus the loft's built as its own body; 0.40.0 fails it
with the error above).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:43:47 -05:00
93 changed files with 2257 additions and 12190 deletions
Generated
+33 -44
View File
@@ -4322,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4365,11 +4365,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-cli"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4385,11 +4385,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-control"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4398,7 +4398,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"image",
@@ -4408,7 +4408,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4417,7 +4417,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"serde",
@@ -4427,7 +4427,6 @@ dependencies = [
"solidcore-tess",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4440,7 +4439,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
"serde_json",
@@ -4451,7 +4450,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4475,18 +4474,9 @@ dependencies = [
"web-time",
]
[[package]]
name = "solidcraft-format"
version = "0.40.1"
dependencies = [
"serde_json",
"sha2 0.10.9",
"zip",
]
[[package]]
name = "solidcraft-geom"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
"serde_json",
@@ -4499,7 +4489,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4524,7 +4514,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4542,7 +4532,6 @@ dependencies = [
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4553,7 +4542,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"proptest",
@@ -4574,7 +4563,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"log",
@@ -4586,7 +4575,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"serde_json",
@@ -4600,14 +4589,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde_json",
"web-time",
@@ -4615,7 +4604,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"image",
@@ -4638,7 +4627,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"harfrust",
"serde",
@@ -4652,7 +4641,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"log",
@@ -4666,18 +4655,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-theme"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"serde",
@@ -4687,7 +4676,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"arboard",
"egui",
@@ -4729,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"arboard",
"egui",
@@ -4752,14 +4741,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"serde_json",
@@ -4769,7 +4758,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"serde",
@@ -4781,7 +4770,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -4792,7 +4781,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"naga",
@@ -4808,7 +4797,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"eframe",
"egui",
@@ -6321,7 +6310,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"proc-macro2",
+1 -3
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.1"
version = "0.40.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -25,8 +25,6 @@ solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
# Versioned file formats: the format tag, the version registry and its upgrade, a newer file told
solidcraft-format = { path = "crates/format" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
+8 -190
View File
@@ -247,7 +247,6 @@
"Drag to move the hole; click to remove it": "Arrossegueu per moure el forat; feu clic per treure'l",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrossegueu per moure el recolzament pel model. S'ajusta als punts del bocet de la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrossegueu per moure l'encaix a pressió pel model. S'ajusta als punts del bocet de la cara.",
"Drag to place it on another face.": "Arrossegueu-lo per col·locar-lo en una altra cara.",
"Drag to point the catch in another direction.": "Arrossegueu per orientar el retén en una altra direcció.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrossegueu per girar el recolzament de 5° en 5°. Manteniu Alt o Ctrl per girar-lo lliurement.",
"Drive Joint": "Accionar la unió",
@@ -514,7 +513,6 @@
"Offset Y": "Desplaçament Y",
"Offset Z": "Desplaçament Z",
"On your computer": "Al vostre equip",
"One more stretch of the picked edge, each with its own angle, height and turns": "Un tram més de l'aresta triada, cadascun amb el seu angle, altura i girs",
"Opacity": "Opacitat",
"Open GitHub": "Obrir GitHub",
"Open GitHub draft": "Obrir l'esborrany de GitHub",
@@ -636,7 +634,6 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Treure aquest plegat",
"Remove this point": "Eliminar aquest punt",
"Remove this segment": "Eliminar aquest segment",
"Rename Feature": "Canviar el nom de l'operació",
"Rename Folder": "Canviar el nom de la carpeta",
"Render Stage": "Escenari de renderitzat",
@@ -726,7 +723,6 @@
"Section position": "Posició de la secció",
"Sections": "Seccions",
"Sections (one sketch each)": "Seccions (un bocet cadascuna)",
"Segments": "Segments",
"Select Features": "Seleccionar les operacions",
"Select Other": "Seleccionar-ne un altre",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar-ne un altre: manteniu el botó esquerre quiet sobre el model (o feu clic amb el botó dret › Seleccionar-ne un altre…) per llistar tot el que hi ha sota el punter; torneu a fer clic al mateix lloc per seleccionar el següent element. Feu doble clic amb el botó central per tornar a Inici (Preferències › Navegació).",
@@ -776,7 +772,6 @@
"Spacing must be a non-zero number": "L'espaiat ha de ser un nombre diferent de zero",
"Specialise": "Especialitzar",
"Speed": "Velocitat",
"Split Edge": "Dividir aresta",
"Splitting tool": "Eina de divisió",
"Square": "Quadrat",
"Stainless Steel": "Acer inoxidable",
@@ -937,20 +932,13 @@
"ai.http.confirm": "Permetre per a aquest servei",
"ai.http.warning": "La clau de l'API i la conversa s'enviaran sense xifrar. Qualsevol persona amb accés a la xarxa les pot llegir o canviar. Feu-ho servir només en una xarxa de confiança.",
"ai.prefs.allow_control": "Permetre que es connectin agents d'IA",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, al port de sota; 27878 pren el següent lliure quan està en ús). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica a l'instant. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, port 27878 o el següent lliure). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica la propera vegada que s'iniciï SolidCraft. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.availability": "Disponibilitat",
"ai.prefs.background_commands": "Executar les ordres de l'agent en segon pla",
"ai.prefs.connect_apps": "Posar SolidCraft a disposició de les aplicacions d'IA d'aquest equip",
"ai.prefs.connect_apps_note": "Desactivat: treu les entrades que havia afegit.\n\nEn iniciar SolidCraft, l'afegeix com a servidor MCP a les aplicacions d'IA que trobi en aquest equip (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), perquè un agent el pugui fer servir sense configurar res. Només s'afegeix l'entrada solidcraft o se n'actualitza el camí; una entrada que hàgiu canviat es deixa intacta, i el fitxer es copia abans. Desactivat: s'eliminen les entrades que havia afegit. Ajuda › Connectar assistents d'IA… ho fa a mà.",
"ai.prefs.connect_at_start": "Connectar el darrer agent en iniciar",
"ai.prefs.connect_at_start_note": "Inicia en segon pla l'agent que vau connectar per última vegada (o el predeterminat) cada vegada que s'inicia SolidCraft, perquè estigui a punt per xatejar. No quan SolidCraft s'inicia amb --control. Si no es pot connectar, l'Assistent mostra Connexió fallida i un botó Reintentar.",
"ai.prefs.control_failed": "No s'ha pogut iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciat amb --control al port {port} per a aquesta execució. Els canvis d'aquí s'apliquen la propera vegada que SolidCraft s'iniciï sense --control.",
"ai.prefs.control_listening": "Escoltant a 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No està escoltant",
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "El port al qual es connecten els agents d'IA en aquest equip (127.0.0.1). 27878 és el predeterminat: quan està en ús, es pren el següent lliure. Qualsevol altre port s'usa exactament. Apunteu el vostre client MCP al mateix port, per exemple solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El port {port} està en ús per un altre programa (o una altra finestra de SolidCraft). Trieu un altre port.",
"ai.prefs.follow_camera": "Girar la càmera cap als bocets",
"ai.prefs.follow_camera_off": "El seguiment automàtic de la càmera està desactivat.",
"ai.prefs.follow_camera_on": "La càmera gira cap als bocets que crea l'agent.",
@@ -1011,13 +999,6 @@
"browser.sketches_to_parts_not_on_face": "no en una cara de peça",
"browser.sketches_to_parts_part_missing": "la seva peça ha desaparegut",
"browser.sketches_to_parts_result": "S'han mogut {moved} bocets a la peça on són; {stayed} s'han quedat: {why}",
"browser.straighten_angle": "Direcció de l'aresta",
"browser.straighten_choose": "Trieu Horitzontal, Vertical o Angle per a l'aresta i premeu Redreçar.",
"browser.straighten_click_a": "Feu clic a la imatge en un extrem d'una aresta que hagi de quedar recta, o escriviu-ne les coordenades.",
"browser.straighten_click_b": "Feu clic a l'altre extrem de l'aresta, o escriviu-ne les coordenades.",
"browser.straighten_horizontal": "Horitzontal",
"browser.straighten_title": "Redreçar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unitats: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1047,8 +1028,6 @@
"cmd.appearance_pick_up": "Agafar l'aparença",
"cmd.appearance_replace": "Substituir l'aparença",
"cmd.assembly_bom": "Llista de materials",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar la restricció",
"cmd.assembly_mass_properties": "Propietats de massa de l'ensamblatge",
"cmd.body.print_face": "Cara d'impressió",
"cmd.body_rename": "Canviar el nom del cos",
@@ -1067,7 +1046,6 @@
"cmd.canvas_delete": "Eliminar el llenç",
"cmd.canvas_edit": "Editar el llenç",
"cmd.canvas_insert": "Llenç",
"cmd.canvas_straighten": "Redreçar el llenç",
"cmd.canvas_to_sketch": "Llenç a bocet",
"cmd.component_activate": "Activar el component",
"cmd.component_break_link": "Trencar el vincle",
@@ -1109,10 +1087,7 @@
"cmd.config_rule": "Regla de configuració",
"cmd.config_rules": "Regles de configuració",
"cmd.config_table": "Mostrar la taula de configuracions",
"cmd.construct.axis": "Eix",
"cmd.construct.plane": "Pla",
"cmd.construct.plane.axis_point": "Pla per eix i punt",
"cmd.construct.point": "Punt",
"cmd.construct_axis_edge": "Eix per una aresta",
"cmd.construct_axis_normal_to_face": "Eix perpendicular a una cara",
"cmd.construct_axis_through_cylinder": "Eix per un cilindre, con o tor",
@@ -1175,7 +1150,6 @@
"cmd.file_save_mesh": "Desar com a malla",
"cmd.file_send_to_slicer": "Enviar al laminador",
"cmd.file_slicers": "Laminadors instal·lats",
"cmd.frame_end_cap": "Tapa d'extrem",
"cmd.frame_member": "Element estructural",
"cmd.frame_profile_insert": "Inserir un perfil",
"cmd.frame_profile_list": "Biblioteca de perfils",
@@ -1196,7 +1170,6 @@
"cmd.inspect_isocurve": "Anàlisi d'isocorbes",
"cmd.inspect_minimum_radius": "Anàlisi de radi mínim",
"cmd.inspect_parameter_sweep": "Escombrat de paràmetres",
"cmd.inspect_parameter_variants": "Comparar variants de paràmetres",
"cmd.inspect_silhouette": "Silueta",
"cmd.inspect_zebra": "Anàlisi de franges de zebra",
"cmd.joint_as_built": "Unió segons la posició actual",
@@ -1250,10 +1223,6 @@
"cmd.motion_trace": "Traça del moviment",
"cmd.motion_trace_edit": "Editar la traça del moviment",
"cmd.motion_trace_keep": "Conservar la traça del moviment",
"cmd.note_add": "Afegir una nota",
"cmd.note_delete": "Eliminar la nota",
"cmd.note_list": "Llistar les notes",
"cmd.note_update": "Editar la nota",
"cmd.occurrence_align": "Alinear la instància",
"cmd.occurrence_copy": "Enganxar (instància)",
"cmd.occurrence_drag": "Arrossegar la instància",
@@ -1379,7 +1348,6 @@
"cmd.render_start": "Renderitzar la imatge",
"cmd.render_status": "Progrés del renderitzat",
"cmd.select_clear": "Esborrar la selecció",
"cmd.selection_get": "Obtenir la selecció",
"cmd.settings_profile_apply": "Aplicar el perfil d'ajustos",
"cmd.settings_profile_delete": "Eliminar el perfil d'ajustos",
"cmd.settings_profile_export": "Exportar el perfil d'ajustos",
@@ -1543,7 +1511,6 @@
"cmd.timeline_suppress": "Suprimir l'operació",
"cmd.ui.assistant": "Alternar l'Assistent",
"cmd.ui.palette": "Cercar ordres",
"cmd.ui.toggle_visibility": "Mostrar/amagar la selecció",
"cmd.view.navigator": "Navegador de vistes personalitzat",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Moure l'acoblament del render",
@@ -1601,7 +1568,6 @@
"color.step_down": "Baixar un pas",
"color.step_up": "Pujar un pas",
"color.value": "Valor, la brillantor (percentatge)",
"component.error.ambiguous_name": "Diversos components es diuen `{name}`. Feu servir un ID numèric de component: {ids}.",
"config.column_filter_empty": "No hi ha columnes coincidents",
"config.rule_parameter_columns": "Només es llisten els paràmetres amb una columna a la taula. Afegiu-ne una amb Afegir una columna (+) per fer-la servir aquí.",
"config.rule_suppression_columns": "Només es llisten les operacions amb una columna Suprimir a la taula. Afegiu-ne una amb Afegir una columna (+) per controlar-la aquí.",
@@ -1642,7 +1608,6 @@
"diagnostic.reselect_reference": "{feature}: la referència `{reference}` ja no existeix tal com es va triar; torneu-la a seleccionar",
"dialog.bodies_count": "{count} cossos",
"dialog.clearance_invalid": "la folgança ha de ser una longitud de 0 o més",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nou",
"dialog.computing": "Calculant… {seconds} s",
@@ -1655,12 +1620,10 @@
"dialog.dimension_step_expression": "Només s'avança amb un nombre; una expressió es queda tal com s'escriu",
"dialog.dimension_step_up": "Pujar {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "L'aresta de referència manté aquest forat sobre una línia que surt de la cara abans del punt triat. Trieu on la línia travessa la cara, o traieu l'aresta per moure el forat lliurement.",
"dialog.no_shown_body": "No hi ha cap cos mostrat per canviar: mostreu-ne un o trieu Cos nou",
"dialog.pattern_sketch_not_object": "Trieu cossos o operacions: un bocet no és cap de les dues coses. Repetiu les seves corbes amb el Patró o la Simetria del propi bocet, o extruïu-lo primer.",
"dialog.primitive_point_tip": "Un punt que omple X, Y i Z: feu clic aquí i després feu clic en un vèrtex, una aresta o una cara del model, un punt d'un bocet o l'origen.",
"dialog.reference_edge_tip": "Una aresta recta de la cara triada per mesurar-hi la distància de sota: feu clic aquí i després feu clic a l'aresta.",
"dialog.right_click_pick": "Seleccioneu {input} primer",
"dialog.select": "↖ Seleccionar",
"dialog.selected_count": "↖ {count} seleccionats",
"dialog.selection_constraint": "Restricció {id}",
@@ -1674,12 +1637,9 @@
"document.changed_on_disk": "{name} ha canviat al disc",
"document.changed_on_disk_detail": "Un altre programa o un altre equip ha modificat {name} des que el vau obrir o desar per última vegada. Sobreescriure substitueix aquests canvis pels vostres; Desar una còpia conserva tots dos.",
"document.copy_name": "Còpia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Acabeu de respondre l'avís de desar abans d'obrir un altre disseny.",
"document.newer_file": "Desat amb una versió més recent de SolidCraft (format {format}; aquesta versió llegeix fins a {reads}). Si falta alguna cosa, actualitzeu.",
"document.newer_file_change": "Desat amb una versió més recent de SolidCraft (format {format} amb `{change}`, que aquesta versió no coneix). Si falta alguna cosa, actualitzeu.",
"document.newer_file_not_read": "No llegit: {what}. En desar es conserva.",
"document.not_saved_yet": "Encara sense desar",
"document.open_anyway": "Obrir de tota manera",
"document.open_elsewhere": "{name} ja està obert",
"document.open_elsewhere_detail": "{name} està obert a SolidCraft {app} a {who}, obert {when}. Si deseu tots dos, un de vosaltres perdrà la feina. Obriu-lo en només lectura per veure'l sense sobreescriure'l.",
@@ -1689,9 +1649,6 @@
"document.read_only_save": "Aquest disseny s'ha obert en només lectura: deseu-lo com a fitxer nou",
"document.save_changes": "Voleu desar els canvis de {name}?",
"document.save_copy": "Desar una còpia…",
"document.save_many": "Voleu desar els canvis de {n} dissenys?",
"document.save_row": "Desar",
"document.save_selected": "Desar els seleccionats",
"document.tab_unsaved_changes": "Canvis sense desar",
"document.unsaved_changes": "{name} té canvis sense desar. Si no els deseu, es perdran.",
"drive.rigid": "{joint} és una unió rígida: manté les seves peces quietes, així que no té res a accionar. Per deixar-les moure, canvieu-ne el tipus a Editar la unió: Revolució per girar, Lliscador per lliscar, Cilíndrica per totes dues.",
@@ -1711,8 +1668,6 @@
"dxf.tolerance": "Tolerància de corba",
"dxf.tolerance_help": "Només cossos visibles i bocets mostrats. Les corbes fan servir segments rectes. Una tolerància més petita dóna corbes més fines i consumeix més temps i memòria.",
"each rail's curves must be in one sketch": "les corbes de cada guia han de ser en un sol bocet",
"error.end_cap_faces": "Trieu almenys una cara d'extrem oberta d'un element estructural",
"error.end_cap_unresolved": "L'extrem {ends} d'aquesta tapa ja no és al model. Torneu a triar les cares d'extrem",
"feature computation ended without a result": "el càlcul de l'operació s'ha acabat sense resultat",
"file.open.drawing_filter": "Dissenys, fitxers CAD, malles i dibuixos",
"file.open.portal_fallback.no_portal_service": "El selector de fitxers que s'ha obert no és el propi de l'escriptori: el servei del portal de l'escriptori no està disponible.",
@@ -1724,7 +1679,6 @@
"file_load.inserting": "Inserint la geometria",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "S'ha obert {name}",
"file_load.opened_behind": "{name} s'ha obert en una pestanya nova",
"file_load.opened_in_tab": "S'ha obert {name} en una pestanya nova",
"file_load.opening": "Obrint el disseny",
"file_load.preparing_thumbnail": "Preparant la miniatura…",
@@ -1772,13 +1726,11 @@
"help.config.table": "Mostra la taula de configuració: una fila per variant, una columna per paràmetre o operació, i si el disseny encara coincideix amb la fila activa. Feu-ho servir per revisar les variants d'un disseny.",
"help.connect_note": "Reinicieu cada assistent perquè carregui el servidor de SolidCraft. Cada fitxer s'ha desat com a .bak abans de modificar-lo.",
"help.connect_title": "Connectar assistents d'IA",
"help.construct.axis": "Crea un eix de construcció amb el mètode que demanin les vostres seleccions: al llarg d'una aresta o línia, per un forat o una cara rodona, on es troben dos plans, per dos punts, o perpendicular a una cara per un punt. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.axis.edge": "Crea un eix de construcció al llarg d'una aresta recta o una línia de bocet. Feu-lo servir com a eix per a una revolució, un patró circular o una unió.",
"help.construct.axis.normal_to_face": "Crea un eix de construcció per un punt, perpendicular a una cara. Feu-lo servir com a eix que surt recte d'una superfície.",
"help.construct.axis.through_cylinder": "Crea un eix de construcció pel centre d'una cara de cilindre, con o tor, o una aresta circular. Feu-lo servir per girar o fer un patró d'operacions al voltant d'una peça rodona.",
"help.construct.axis.two_planes": "Crea un eix de construcció on es troben dos plans o cares planes. Feu-lo servir quan l'eix que necessiteu és la línia entre dues cares.",
"help.construct.axis.two_points": "Crea un eix de construcció per dos punts, vèrtexs o punts de bocet. Feu-lo servir per a un eix que va entre dues posicions conegudes.",
"help.construct.plane": "Crea un pla de construcció amb el mètode que demanin les vostres seleccions: desplaçat respecte d'un pla, per un punt, entre dos plans, per línies o punts, tangent a una cara corba, o perpendicular a una trajectòria. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.plane.along_path": "Crea un pla de construcció perpendicular a una aresta o corba, en una posició al llarg seu. Feu-lo servir per bocetar un perfil per a un escombrat o una secció d'una trajectòria.",
"help.construct.plane.at_angle": "Crea un pla de construcció girat un angle des d'un pla o cara, al voltant d'un eix o aresta. Activeu «Fer servir només la direcció de l'eix» per projectar l'eix sobre la base i pivotar al voltant d'un punt d'aquest pla (l'origen de la base quan no se'n tria cap). Feu-lo servir per a bocets inclinats, com un suport angular.",
"help.construct.plane.axis_point": "Crea un pla de construcció que conté un eix i un punt fora d'aquest eix. Trieu un origen o eix de construcció, una aresta recta o línia de bocet, i després un vèrtex, punt de bocet o punt de construcció. Un punt a l'eix no pot definir un pla únic.",
@@ -1788,7 +1740,6 @@
"help.construct.plane.tangent": "Crea un pla de construcció que toca un cilindre, con, esfera o tor en un punt. Feu-lo servir per bocetar al costat d'una peça rodona.",
"help.construct.plane.three_points": "Crea un pla de construcció per tres punts. Feu-lo servir per a un pla definit per posicions del model, com tres cantonades.",
"help.construct.plane.two_edges": "Crea un pla de construcció per dues arestes rectes o línies que són en un mateix pla. Feu-lo servir per a un pla a través d'una peça, com per dues arestes oposades.",
"help.construct.point": "Crea un punt de construcció amb el mètode que demanin les vostres seleccions: en un vèrtex, al centre d'un cercle o una esfera, al llarg d'una aresta, on es troben dues línies o tres plans, o on una línia travessa un pla. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.point.along_path": "Crea un punt de construcció en una posició al llarg d'una aresta o corba. Feu-lo servir com a referència a mig camí d'una trajectòria.",
"help.construct.point.center": "Crea un punt de construcció al centre d'una aresta circular, cercle o arc de bocet, esfera o tor. Feu-lo servir com a referència per a forats, eixos i unions.",
"help.construct.point.edge_plane": "Crea un punt de construcció on una aresta o línia travessa un pla o cara plana. Feu-lo servir per marcar on una trajectòria creua un pla.",
@@ -1814,7 +1765,6 @@
"help.file.send_to_slicer": "Desa els cossos seleccionats com a fitxer 3MF i obre'l al laminador que trieu. Feu-ho servir per passar del model a la impressió en un sol pas.",
"help.font_details": "La interfície fa servir les fonts integrades d'egui (Ubuntu Light i Hack, sota la Ubuntu Font Licence i la llicència MIT/Bitstream Vera; Noto Emoji i la font d'icones emoji, sota l'OFL i MIT). El text dels bocets fa servir Liberation Sans (SIL OFL 1.1). El paquet opcional de fonts de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no s'inclou a les compilacions de SolidCraft.",
"help.fonts": "Fonts",
"help.frame.end_cap": "Tanca els extrems oberts dels elements estructurals amb plaques amb la forma del contorn del perfil. Feu-lo servir per segellar tubs, angulars i bigues, a sobre de l'extrem o encastat-hi.",
"help.frame.member": "Col·loca un perfil, com un tub o una extrusió d'alumini, al llarg de les línies d'un bocet d'estructura, un element per línia. Feu-ho servir per construir bastiments, prestatges i estructures soldades.",
"help.inspect.accessibility": "Coloreja les cares que una eina pot assolir des d'una direcció i les que no. Feu-ho servir per comprovar una peça per a mecanitzat o motlle des d'aquell costat.",
"help.inspect.center_of_mass": "Mostra el centre de masses dels cossos, cadascun per separat i tots junts, amb densitat uniforme. Feu-ho servir per comprovar l'equilibri i on bolcarà una peça.",
@@ -1941,13 +1891,13 @@
"help.sketch.construction_line": "Crea una línia de construcció infinita que passa per dos punts. Feu-la servir per estructurar un bocet sense partir perfils.",
"help.sketch.construction_line.angle": "Crea una línia de construcció infinita amb un angle respecte de l'eix X del bocet. Trieu el punt i escriviu l'angle, o trieu-ne la direcció.",
"help.sketch.construction_line.bisector": "Trieu dues línies rectes del bocet que es tallin i després una de les dues bisectrius de l'angle. El resultat segueix les seves línies d'origen.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passi per un punt, o escriviu-ne la distància a l'eix X del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta de bocet i després col·loqueu una línia de construcció paral·lela infinita o escriviu-ne el desplaçament. Un desplaçament escrit es converteix en una distància controladora. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passa per un punt. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta del bocet i col·loqueu una línia de construcció paral·lela infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.perpendicular": "Trieu una línia recta del bocet i col·loqueu una línia de construcció perpendicular infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.tangent_at": "Crea una tangent en un punt d'un cercle o arc circular, o en un extrem d'una spline o cònica oberta. Trieu la corba i després el punt de contacte.",
"help.sketch.construction_line.tangent_external": "Trieu un cercle o arc circular, després un punt exterior i una solució de tangent. Les previsualitzacions només mostren contactes a l'arc dibuixat.",
"help.sketch.construction_line.tangent_two": "Trieu dos cercles o arcs circulars i després una tangent comuna a la previsualització. Els contactes han de ser a les corbes dibuixades.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passi per un punt, o escriviu-ne la distància a l'eix Y del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passa per un punt. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.copy": "Copia les corbes i punts de bocet seleccionats, juntament amb les restriccions i cotes que hi ha entre ells. Enganxeu-los en aquest bocet o en un altre.",
"help.sketch.create": "Inicia un bocet 2D nou en un pla o en una cara plana del model. Cada extrusió, revolució i escombrat comença amb un bocet.",
"help.sketch.cut": "Copia la geometria de bocet seleccionada i l'esborra del bocet, en un sol pas de desfer. Enganxeu-la en un altre lloc d'aquest bocet o en un altre.",
@@ -2291,9 +2241,6 @@
"measure.at": "A",
"measure.item": "Element {number}",
"measure.line_to_centre": "Línia al centre",
"measure.perimeter": "Perímetre",
"measure.perimeter_inner": "Perímetre interior",
"measure.perimeter_outer": "Perímetre exterior",
"measure.quick_copied": "Mesura copiada",
"measure.quick_copy": "Feu clic per copiar la mesura",
"measure.quick_edges": "{count} arestes",
@@ -2335,8 +2282,6 @@
"nav.hfss.help": "Alt+arrossegar esquerre desplaça, Majúscules+Alt+arrossegar esquerre fa zoom, Ctrl+arrossegar esquerre en un espai buit orbita",
"nav.inventor.help": "Arrossegar amb el botó central desplaça, Majúscules+central orbita, Ctrl+central fa zoom i arrossegar amb el dret orbita",
"nav.maya.help": "Alt+arrossegar esquerre orbita, Alt+arrossegar central desplaça, Alt+arrossegar dret fa zoom; arrossegar central també desplaça",
"nav.maya_horizontal_zoom.help": "Alt+arrossegar amb el botó dret: cap a la dreta apropa, cap a l'esquerra allunya. Desactiveu-ho per tornar al zoom arrossegant en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrossegant en horitzontal",
"nav.nx.help": "Arrossegar amb el botó central orbita, Majúscules+central desplaça (central+dret de NX), Ctrl+central fa zoom (central+esquerre de NX); arrossegar amb el dret deixa la càmera quieta",
"nav.openscad.help": "Arrossegar amb el botó esquerre en un espai buit orbita, Majúscules+esquerre selecciona amb un rectangle i arrossegar geometria del bocet l'edita; arrossegar amb el dret o el central desplaça, i Majúscules+dret o Majúscules+central fa zoom",
"nav.orbit_cursor_wrap.help": "Un arrossegament d'òrbita que arriba a la vora de la vista mou el cursor a la vora oposada i continua girant. Desactivat: l'arrossegament s'atura a la vora de la pantalla. No té efecte a Wayland ni al navegador.",
@@ -2467,16 +2412,6 @@
"not a move": "no és un moviment",
"not a number": "no és un nombre",
"open a sketch first": "obriu primer un bocet",
"operations.cancel": "Cancel·lar {name}",
"operations.cancelled": "{name} cancel·lat",
"operations.cancelling": "Cancel·lant…",
"operations.cannot_cancel": "{name} no es pot cancel·lar",
"operations.feature": "Operació",
"operations.feature_edit": "Editar l'operació",
"operations.meshing": "Carregant les peces",
"operations.queued": "A la cua",
"operations.stopped": "Cancel·lat",
"operations.waiting": "A punt",
"parameters.apply_live": "Aplicar cada canvi en sortir del camp",
"parameters.apply_live_tip": "Desactivat: els canvis esperen fins que premeu Aplicar o D'acord, i llavors el disseny es reconstrueix una sola vegada per a tots. Cancel·lar o Escape els descarta.",
"parameters.held": "{count} canvis esperant Aplicar",
@@ -2565,7 +2500,6 @@
"prefs.appearance.viewport_colours": "Colors de la vista 3D",
"prefs.appearance.viewport_logo": "Visor i logotip",
"prefs.applies_on_restart": "S'aplica en reiniciar",
"prefs.assemblies": "Ensamblatges",
"prefs.assistant": "Assistent",
"prefs.assistant_connecting": "Connectant",
"prefs.assistant_follow_help": "Els bocets Mirar cap a mouen la vista amb el punter.",
@@ -2618,10 +2552,6 @@
"prefs.choose_font": "Triar un fitxer de tipus de lletra…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diàlegs d'ordres",
"prefs.constraint_glyph_size": "Mida dels glifs de restricció",
"prefs.constraint_glyph_size_note": "Com de grans es dibuixen els glifs de restricció i les seves lletres o icones en un bocet. El 100 % és la mida habitual; els glifs conserven aquesta mida en pantalla amb qualsevol zoom.",
"prefs.construct_menu_groups": "Agrupar les eines de Construir per tipus d'entitat",
"prefs.construct_menu_groups_help": "Mostra els encapçalaments Plans, Eixos i Punts. Desactivat, es manté l'ordre original del menú.",
"prefs.contact_shading": "Ombrejat de contacte suau",
"prefs.contact_shading_note": "Ombrejat suau on les peces s'acosten.",
"prefs.context_menu": "Menú contextual",
@@ -2657,8 +2587,6 @@
"prefs.design_tools.rows": "Files",
"prefs.devices": "Ratolí 3D",
"prefs.dialog_buttons": "Botons de diàleg",
"prefs.dialog_first_letter_buttons": "Els botons de diàleg responen a la seva primera lletra",
"prefs.dialog_first_letter_buttons_help": "Amb un diàleg obert, escriure la primera lletra de l'etiqueta d'un botó el prem: O per a OK, C per a Cancel·la. Una lletra que encaixa en dos botons no fa res, i un camp de text amb el focus conserva la pulsació. Desactivat: escriure no afecta els botons, com abans.",
"prefs.dialogs": "Diàlegs",
"prefs.dialogs_edge": "A la vora de la vista",
"prefs.dialogs_left_column": "A la columna esquerra",
@@ -2672,23 +2600,12 @@
"prefs.dimension_values_window": "En una finestra",
"prefs.display": "Pantalla",
"prefs.edges": "Arestes i ombrejat",
"prefs.export": "Exportar",
"prefs.export_name": "Nom del fitxer d'exportació",
"prefs.export_name.body": "Nom del cos exportat (el primer quan n'hi ha diversos)",
"prefs.export_name.design": "Nom del disseny",
"prefs.export_name_hint": "p. ex., {design}-{body}",
"prefs.export_name_sample": "En exportar aquest disseny s'ofereix: {name}",
"prefs.export_name_tip": "El nom de fitxer que ofereixen Fitxer › Exportar i Desar com a malla. Se substitueixen aquests marcadors:\n{placeholders}\nUna propietat del disseny que no estigui definida s'omet. Deixeu el camp buit per oferir el nom del disseny o, en una malla, el del cos.",
"prefs.extend": "Extensions",
"prefs.extra_navigation": "Ctrl + arrossegar amb el botó esquerre sobre un espai buit orbita; manteniu l'Espai per desplaçar.",
"prefs.extrude_arrow_face_stops": "La fletxa d'extrusió s'atura a les cares",
"prefs.extrude_arrow_face_stops_note": "En arrossegar la fletxa de distància d'Extruir, s'atura a les cares planes paral·leles al perfil, com el costat oposat d'un bloc que s'està tallant, i ressegueix la cara. Desactivat: la fletxa només es mou a passos rodons.",
"prefs.extrude_view_isometric": "Girar la vista a isomètrica en obrir Extruir",
"prefs.extrude_view_return": "Tornar a la vista anterior en tancar Extruir",
"prefs.file_operations_wait": "Esperar que s'obrin els fitxers",
"prefs.file_operations_wait_note": "Mentre s'obre o s'insereix un fitxer, o es tornen a llegir els vincles, el disseny espera darrere una targeta amb el progrés del fitxer i Cancel·la. Desactivat: el fitxer apareix al panell d'operacions i podeu seguir treballant; una inserció s'aplica quan acabeu el que esteu fent, i un fitxer obert mentrestant va a una pestanya nova.",
"prefs.fillet_legacy_radius": "Usar el radi predeterminat anterior d'Arrodoniment de sòlid",
"prefs.fillet_legacy_radius.help": "Desactivat de manera predeterminada. Activat, un nou Arrodoniment de sòlid s'obre amb el número 2 llegit en les unitats del disseny («2 in» en un disseny en polzades), com abans; desactivat, s'obre amb el radi que porta l'eina, 2 mm, mostrat en les unitats del disseny («0.07874 in» en un disseny en polzades).",
"prefs.follows_os": "Segueix el sistema operatiu",
"prefs.font": "Tipus de lletra",
"prefs.font_fallback": "Els glifs que no té el tipus de lletra triat es prenen del tipus integrat, així cap etiqueta no mostra un requadre buit.",
@@ -2712,17 +2629,6 @@
"prefs.gesture_pan": "Desplaçar",
"prefs.gesture_zoom": "Zoom",
"prefs.graphics": "Gràfics",
"prefs.graphics_api": "API gràfica",
"prefs.graphics_api_auto": "Automàtica",
"prefs.graphics_api_help": "Trieu l'API gràfica que s'usa per obrir SolidCraft. Si el proper inici no pot obrir la finestra, es restaura la tria anterior.",
"prefs.graphics_api_override": "Una opció d'inici controla l'API gràfica. La tria desada s'aplicarà quan SolidCraft s'obri normalment.",
"prefs.graphics_api_restart_note": "S'aplica després de reiniciar. Els dissenys sense desar es gestionen abans de tancar.",
"prefs.graphics_api_restart_unavailable": "Reinicieu SolidCraft per aplicar l'API gràfica desada.",
"prefs.graphics_api_running": "Actual: {current}. Triada per al proper inici: {selected}.",
"prefs.graphics_api_save_failed": "No s'ha pogut desar la tria d'API gràfica: {error}",
"prefs.graphics_api_start_failed": "No s'ha pogut preparar l'API gràfica triada: {error}",
"prefs.graphics_api_start_reverted": "L'inici anterior amb {requested} no va obrir la finestra. SolidCraft ha restaurat {previous}.",
"prefs.graphics_api_unavailable": "La selecció de l'API gràfica està disponible a l'aplicació d'escriptori.",
"prefs.graphics_card": "Targeta gràfica",
"prefs.graphics_card_auto": "Automàtica",
"prefs.graphics_card_missing": "La targeta gràfica desada no està disponible; s'usa Automàtica.",
@@ -2766,8 +2672,6 @@
"prefs.history_sketches_in_order": "En ordre",
"prefs.history_sketches_note": "Amb Bocets a Sota la seva operació, l'historial de l'esquerra llista cada bocet un nivell endins, sota la primera operació construïda a partir d'ell, sempre que hi hagi prou amplada per als noms. La línia de temps de baix manté sempre l'ordre de creació.",
"prefs.history_sketches_under_feature": "Sota la seva operació",
"prefs.history_steps_component": "Passos de l'historial per component",
"prefs.history_steps_component_note": "Amb un component actiu o seleccionat, els controls de l'Historial recorren les operacions d'aquest component, tal com les mostra l'Historial de baix. Desactivat: recorren totes les operacions del disseny. L'Historial de l'esquerra o sota el navegador sempre recorre totes les operacions.",
"prefs.hold_stop_snapping": "Mantenir premut per aturar l'ajust",
"prefs.home_fit_size": "Mida d'Inici i Ajustar",
"prefs.home_fit_size_note": "Quant de la vista omple el model quan Inici, Ajustar, un disseny obert o un canvi de vista del navegador l'enquadren, tant si l'ajust de sota està activat com si no. 100 %: omple la vista entre els panells, la brúixola i la barra de navegació.",
@@ -2783,8 +2687,6 @@
"prefs.invert_curved_roll_arrows_note": "Les dues fletxes corbes a la cantonada del cub giren la vista dins del pla de la pantalla. Activades: la fletxa horària gira en sentit antihorari i l'antihorària gira en sentit horari. Desactivades: cada fletxa gira la vista cap on es corba.",
"prefs.invert_navigator_arrows": "Invertir les fletxes de rotació del cub de vista",
"prefs.invert_navigator_arrows_note": "Les fletxes al voltant del cub orbiten la càmera cap on apunten, com a Fusion i Inventor: la fletxa de l'esquerra apunta a la dreta i gira la càmera a la dreta. Desactivades: una fletxa gira el cub cap al costat on és, així la fletxa de l'esquerra gira la càmera a l'esquerra. Les fletxes de gir corbes no en queden afectades.",
"prefs.joint_drag_trackball": "Girar les peces d'una unió de ròtula com un trackball en arrossegar-les",
"prefs.joint_drag_trackball_help": "Arrossegar una peça que subjecta una unió de ròtula la gira amb el gir més petit possible al voltant de la unió, com si rodés una bola de la mida de la peça. Desactivat: el punt que heu agafat segueix exactament el punter, i la peça també pot girar sobre ell.",
"prefs.keep_unsaved_also_asks": "Continuar preguntant quan es tanca la pestanya",
"prefs.keep_unsaved_also_asks_tip": "Amb això desactivat, la pestanya es tanca sense la finestra del navegador un cop la còpia de cada disseny sense desar està al dia.",
"prefs.keep_unsaved_in_browser": "Conservar la feina sense desar en aquest navegador",
@@ -2860,8 +2762,6 @@
"prefs.not_on_gpu": "no en aquesta GPU",
"prefs.on": "Activat",
"prefs.opaque": "Opac",
"prefs.operations_panel": "Mostrar les operacions a la vista",
"prefs.operations_panel_note": "Les operacions llargues (obrir un fitxer, calcular una operació, una ordre de l'agent) es mostren a la cantonada inferior dreta de la vista amb el seu progrés i un botó per cancel·lar-les. Desactivat: un avís de «Calculant», com abans.",
"prefs.orbit_snap": "Manteniu el Majúscules mentre orbiteu per ajustar a un eix.",
"prefs.orientation": "Orientació",
"prefs.origin_plane_names": "Noms dels plans d'origen",
@@ -2942,8 +2842,6 @@
"prefs.ribbon_tabs_only": "Mostrar només les pestanyes de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta es plega fins a la seva fila de pestanyes. En fer clic en una pestanya es mostren els seus panells sobre la vista fins que comença una ordre o el punter en surt. «Només les pestanyes de la cinta», a la pestanya Vista o amb la seva drecera, ho commuta.",
"prefs.right_click": "Clic dret",
"prefs.save_prompt_one_at_a_time": "Preguntar per cada disseny sense desar per separat en tancar-ne diversos",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un avís de desar per cada disseny sense desar en lloc d'una llista combinada.",
"prefs.saving": "Inici i desament",
"prefs.scale_note": "També Ctrl + / Ctrl − a la finestra. Els botons fan 28 px al 100 %.",
"prefs.scale_text": "Escala i text",
@@ -2980,8 +2878,6 @@
"prefs.sketch.center_arc": "L'arc pel centre segueix el cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicte",
"prefs.sketch.cursor_coordinates": "Mostrar les coordenades del cursor en un bocet",
"prefs.sketch.cursor_coordinates_note": "Al costat del punter, la seva X i Y des de l'origen del bocet, també sense cap eina en marxa. Desactivat: només mentre una eina col·loca un punt.",
"prefs.sketch.drawing": "Dibuix",
"prefs.sketch.ellipse_axes": "Dibuixar les el·lipses amb els seus eixos",
"prefs.sketch.ellipse_axes_note": "Els eixos major i menor com a línies de construcció, per poder acotar els semieixos i els eixos. Desactivat: només l'el·lipse, i D a sobre seu dóna el radi menor allà on es tiri el valor, dibuixat i etiquetat R com abans.",
@@ -2993,20 +2889,14 @@
"prefs.sketch.faces_edges": "Cares i arestes",
"prefs.sketch.hide_glyphs": "Amagar els glifs en allunyar-se",
"prefs.sketch.marks": "Marques al bocet",
"prefs.sketch.max_points": "Bocet més gran",
"prefs.sketch.max_points_note": "El màxim de punts o corbes que admet un bocet: 20000 de manera predeterminada, com a molt 200000. Inserir SVG i Inserir DXF refusen un dibuix més gran. Un bocet gran es resol i es dibuixa més lentament.",
"prefs.sketch.max_points_unit": "punts",
"prefs.sketch.note": "Dibuix, ajust i selecció.",
"prefs.sketch.opening_closing": "Obertura i tancament",
"prefs.sketch.prefer_free_points": "Preferir punts mòbils quan se superposen",
"prefs.sketch.project_options": "Demanar primer les opcions de Projectar",
"prefs.sketch.recent_snap": "Ajustar on es creuen dues guies",
"prefs.sketch.select_3d_curve_points": "Seleccionar punts de corbes de bocet 3D",
"prefs.sketch.select_3d_curve_points_help": "Mostra i permet seleccionar punts de les corbes de bocet 3D dibuixades com a referències de construcció.",
"prefs.sketch.select_other_hidden": "Seleccionar-ne un altre arriba a la geometria oculta",
"prefs.sketch.shortcut_cycles_modes": "La drecera canvia de mode en tornar-la a prémer",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivat: torna a iniciar l'eina.",
"prefs.sketch.snap_to_visible_grid": "Ajustar als encreuaments visibles de la quadrícula",
"prefs.sketch.snapping_guides": "Ajust i guies",
"prefs.sketch.state_icons": "Icones d'estat",
"prefs.sketch.tracking_guides": "Guies de centre i punt mitjà",
@@ -3034,7 +2924,6 @@
"prefs.sketch_selection": "Bocet i selecció",
"prefs.sketch_selection_heading": "Bocet i selecció",
"prefs.sketch_selection_note": "Com s'ajusten els bocets i com seleccionen els clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punt d'un bocet s'ajusta als encreuaments de les línies que dibuixa la quadrícula quan el punter n'apunta un, i si no, a la retícula més fina entre ells. Desactivat: el punt s'ajusta només a la retícula més fina, com abans.",
"prefs.sketch_state_icons": "Icones dels bocets amb el color del seu estat",
"prefs.sketch_state_icons_note": "A l'historial i a l'explorador, la icona d'un bocet pren el color del seu estat (insuficientment restringit, completament restringit o en conflicte) i la seva informació en diu l'estat. Desactivat: tots els bocets tenen la mateixa icona.",
"prefs.sketch_tab_always": "Mantenir visible la pestanya Bocet",
@@ -3556,12 +3445,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersió subsuperficial de CPU necessita la reducció de soroll desactivada. Es conserven els vostres ajustos.",
"render.cpu_subsurface_help": "La dispersió redirigeix la llum dins del vidre tancat. Les taxes positives necessiten il·luminació indirecta amb la reducció de soroll desactivada. La taxa és de col·lisions per mm; 0 no afegeix dispersió. Un biaix de fase positiu afavoreix les direccions cap endavant; un de negatiu, les cap enrere.",
"render.cpu_summary": "Ray tracing de CPU · reflexos · HDR · llums direccionals i puntuals",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads": "Processos de CPU",
"render.cpu_threads_auto": "Automàtic",
"render.cpu_threads_custom": "Personalitzat",
"render.cpu_threads_help": "Automàtic fa servir els workers de CPU disponibles. Un worker s'executa en sèrie. Més workers poden necessitar més memòria.",
"render.cpu_threads_help": "Automàtic fa servir els processos de CPU disponibles. Un procés s'executa en sèrie. Més processos poden necessitar més memòria.",
"render.cpu_threads_single": "Un",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un worker cooperatiu. Es conserva la tria de workers desada per a l'escriptori.",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un procés cooperatiu. Es conserva l'elecció de processos desada per a l'escriptori.",
"render.cpu_transparent_help": "El PNG transparent treu el fons i conserva la cobertura de les vores. L'opacitat del cos i de la cara ha de mantenir-se al 100 %.",
"render.cpu_volume_enable_path": "Activar la il·luminació indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing de CPU · profunditat {depth} · {spp} mostres/píxel · llavor {seed} · ombres traçades. Conserva l'atmosfera definida i el pressupost d'imatge.",
@@ -3736,7 +3625,6 @@
"render.error_pbr_opacity": "el PBR opac requereix opacitat 1",
"render.error_physical_glass": "la recepta triada no és vidre físic",
"render.error_preview_device": "la vista prèvia del producte requereix un dispositiu instal·lat amb búfer de profunditat",
"render.error_product_placement": "col·locació de producte no vàlida",
"render.error_product_triangle": "triangle de producte no vàlid",
"render.error_provisional_identity": "identitat provisional de renderitzat o nombre de mostres no vàlida",
"render.error_quality_bounds": "la projecció de qualitat supera els límits natius",
@@ -3820,10 +3708,6 @@
"render.gpu_path_retry": "Torna-ho a provar",
"render.gpu_seed": "Llavor aleatòria",
"render.gpu_unavailable": "El path tracing de GPU encara no està a punt en aquest dispositiu.",
"render.graphics_api_restart_vulkan": "Reiniciar amb Vulkan",
"render.graphics_api_restart_warning": "Canviar l'API gràfica reinicia SolidCraft i us demana de desar els dissenys sense desar. Si la nova API no pot obrir la finestra, es restaura la tria anterior al proper inici.",
"render.graphics_api_settings": "Configuració gràfica",
"render.graphics_api_vulkan_required": "El path tracing a la GPU necessita Vulkan en aquesta sessió. Reinicieu amb Vulkan o seguiu amb un mètode d'imatge per CPU.",
"render.ground_apply": "Ajustar i desar el terra",
"render.ground_gap": "Espai lliure explícit",
"render.ground_linear_rgb": "Reflectància difusa · RGB lineal",
@@ -4825,8 +4709,6 @@
"sketch.hint.spline3d": "Spline 3D: feu clic als punts d'ajust en 3D; clic dret o Esc per acabar",
"sketch.hint.trim": "Retallar: feu clic al tros de corba que voleu treure, o arrossegueu pels trossos que voleu treure",
"sketch.make_selection": "Convertir la selecció en {linetype}",
"sketch.move_released": "Mogut: s'han deixat anar {count} restriccions que el lligaven al que es queda ({names}).",
"sketch.move_released_one": "Mogut: s'ha deixat anar {count} restricció que el lligava al que es queda ({names}).",
"sketch.open_ends": "Oberts {count}",
"sketch.status_conflict": "restriccions en conflicte",
"sketch.status_fully_constrained": "totalment restringit",
@@ -5061,7 +4943,6 @@
"ui.agent_chat_allow_once": "Permetre una vegada",
"ui.agent_chat_allowed": "Permès",
"ui.agent_chat_answered": "Resposta: {option}",
"ui.agent_chat_answered_in": "Respost en {time}",
"ui.agent_chat_approval": "Permetre que l'agent faci això?",
"ui.agent_chat_attach_selection": "Adjuntar la selecció",
"ui.agent_chat_attach_selection_tip": "Envia amb el missatge el que estigui seleccionat al model (cares, arestes, operacions, cossos) perquè l'agent sàpiga a què us referiu",
@@ -5090,7 +4971,6 @@
"ui.agent_chat_ctx_bodies": "Cossos",
"ui.agent_chat_ctx_elements": "Elements",
"ui.agent_chat_ctx_features": "Operacions",
"ui.agent_chat_ctx_gone": "Ja no és al disseny: s'ha canviat o eliminat.",
"ui.agent_chat_ctx_more": "+{n} més",
"ui.agent_chat_ctx_other": "Altres",
"ui.agent_chat_custom_heading": "Model personalitzat",
@@ -5104,7 +4984,6 @@
"ui.agent_chat_dock_right": "Acoblar a la dreta",
"ui.agent_chat_drop_hint": "Deixeu anar per adjuntar-ho al proper missatge",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Acabat després de {time}",
"ui.agent_chat_exited": "L'agent s'ha aturat.",
"ui.agent_chat_failed_start": "No s'ha pogut iniciar l'agent: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un fitxer CAD s'importa al disseny, no es llegeix",
@@ -5142,7 +5021,6 @@
"ui.agent_chat_model_no_tools": "{model} no pot usar les eines de SolidCraft, així que només pot xatejar. Trieu un model amb suport d'eines.",
"ui.agent_chat_model_rejected": "L'agent no ha acceptat {model}: {error}",
"ui.agent_chat_model_search": "Cercar models…",
"ui.agent_chat_model_search_or_id": "Cerqueu o escriviu qualsevol ID de model…",
"ui.agent_chat_model_tip": "El model que usa l'agent. La vostra tria es recorda per a aquest agent.",
"ui.agent_chat_models_failed": "No s'han pogut llistar els models: {error}",
"ui.agent_chat_models_heading": "Models disponibles",
@@ -5154,7 +5032,6 @@
"ui.agent_chat_next_picture": "Imatge següent",
"ui.agent_chat_no_cli": "No s'ha trobat solidcraft-cli al costat de l'aplicació, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_control": "El punt de connexió del xat de l'agent no s'ha iniciat, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_design_hint": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: no s'ha trobat solidcraft-cli al costat de l'aplicació. Instal·leu SolidCraft amb solidcraft-cli al costat (a Windows, el MSI o el zip portàtil) o definiu SOLIDCRAFT_CLI amb la seva ruta, i torneu a connectar.",
"ui.agent_chat_no_images": "Aquest agent no admet imatges",
"ui.agent_chat_no_picture": "No hi ha cap imatge al porta-retalls",
"ui.agent_chat_no_tool_call": "El model no ha fet servir les eines de SolidCraft, així que el disseny no ha canviat. Trieu un model que admeti crides a eines; vegeu Models locals a la guia del xat de l'agent.",
@@ -5169,7 +5046,7 @@
"ui.agent_chat_permission": "L'agent demana executar: {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_pick_context": "Triar context",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model i premeu OK: s'afegeixen al missatge com una fitxa on és el cursor (escriure @ fa el mateix)",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model perquè l'agent els miri i premeu D'acord",
"ui.agent_chat_placeholder": "Demaneu a l'agent que modifiqui el disseny…",
"ui.agent_chat_placeholder_busy": "Posa un missatge a la cua per quan l'agent acabi (Ctrl+Retorn: enviar ara)",
"ui.agent_chat_pop_out": "Obrir en una finestra pròpia, que podeu moure a una altra pantalla",
@@ -5201,26 +5078,16 @@
"ui.agent_chat_setup_title": "Configurar un assistent",
"ui.agent_chat_side": "Costat del xat de l'agent",
"ui.agent_chat_signed_in": "sessió iniciada",
"ui.agent_chat_snap_none_found": "No s'ha trobat cap agent de codi. El paquet Snap està confinat, així que no pot veure ni iniciar els agents de codi instal·lats en aquest equip. Per usar-los, instal·leu SolidCraft des de l'AppImage o el paquet .deb o .rpm. Un agent que usa una API compatible amb OpenAI també funciona al paquet Snap: configureu-ne un amb Afegir agent.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Cal iniciar sessió",
"ui.agent_chat_state_busy": "Treballant…",
"ui.agent_chat_state_exited": "Agent aturat",
"ui.agent_chat_state_failed": "Connexió fallida",
"ui.agent_chat_state_no_design": "Sense accés al disseny",
"ui.agent_chat_state_off": "Sense connexió",
"ui.agent_chat_state_ready": "A punt",
"ui.agent_chat_state_starting": "Iniciant…",
"ui.agent_chat_stop": "Aturar",
"ui.agent_chat_stop_tip": "Aturar l'agent i tot el que hagi iniciat",
"ui.agent_chat_stop_turn_tip": "Atura l'agent (Esc). Els missatges a la cua es queden a la cua.",
"ui.agent_chat_thinking_for": "Pensant durant {time}",
"ui.agent_chat_tool_running_for": "en execució durant {time}",
"ui.agent_chat_tools_not_started": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: s'ha trobat solidcraft-cli, però les seves eines no s'han iniciat (l'error del xat explica per què). Torneu a connectar; si continua passant, reinstal·leu SolidCraft.",
"ui.agent_chat_transcript_assistant": "Assistent:",
"ui.agent_chat_transcript_user": "Usuari:",
"ui.agent_chat_turn_limit": "Canvis per missatge",
"ui.agent_chat_turn_unlimited": "Sense límit",
"ui.agent_chat_undo_changes": "Desfer aquests canvis",
@@ -5377,9 +5244,7 @@
"ui.browser_bodies_one": "{count} cos",
"ui.browser_bodies_other": "{count} cossos",
"ui.browser_collapse": "Contraure l'explorador",
"ui.browser_collapse_below": "Contraure a sota",
"ui.browser_expand": "Expandir l'explorador",
"ui.browser_expand_below": "Expandir a sota",
"ui.browser_title": "Explorador",
"ui.calibrate": "Calibrar",
"ui.cancel": "Cancel·la",
@@ -5409,7 +5274,6 @@
"ui.collapse": "Contraure",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visió del color",
"ui.colour_vision_bends": "Plecs",
"ui.colour_vision_deutan": "Deuteranòpsia",
"ui.colour_vision_deutan_tip": "Per a una percepció del verd feble o absent",
"ui.colour_vision_failed": "Fallit",
@@ -5439,24 +5303,14 @@
"ui.conic_curve": "Corba cònica",
"ui.constant": "Constant",
"ui.constraints_conflict": "Conflicte de restriccions",
"ui.construct_auto.chosen": "Triat a partir de les vostres seleccions. Trieu una altra referència, o traieu-ne una, per canviar el mètode.",
"ui.construct_auto.no_fit": "Trieu altres referències: cap mètode es pot construir amb aquestes.",
"ui.construct_auto.pick": "Trieu amb què construir-lo: el mètode es dedueix de les vostres seleccions.",
"ui.construct_auto.refs": "Referències",
"ui.construct_auto.select": "Seleccioneu primer les referències amb què construir",
"ui.construct_axes": "Eixos",
"ui.construct_planes": "Plans",
"ui.construct_points": "Punts",
"ui.construction": "Construcció",
"ui.construction.angle_hint": "Línia de construcció: feu clic en un punt i escriviu un angle o trieu una direcció",
"ui.construction.axis_hint": "Línia de construcció: feu clic en un punt o escriviu la seva distància a l'eix del bocet",
"ui.construction.bisector_hint": "Bisectriu: trieu dues línies rectes que es tallin i després la solució volguda",
"ui.construction.common_hint": "Tangent comuna: trieu dos cercles o arcs i després la solució volguda",
"ui.construction.contact_hint": "Tangent: trieu un cercle, arc o spline o cònica oberta i després el punt de contacte (corbes lliures: extrems)",
"ui.construction.external_hint": "Tangent: trieu un cercle o arc, un punt exterior i després la solució volguda",
"ui.construction.free_hint": "Línia de construcció: feu clic en dos punts; feu clic amb el botó dret per reiniciar",
"ui.construction.normal_needs_length": "Una línia infinita és sempre de construcció: escriviu una longitud per dibuixar una línia normal",
"ui.construction.parallel_hint": "Línia de construcció: trieu una línia recta i després feu clic o escriviu el desplaçament; feu clic amb el botó dret per canviar la referència",
"ui.construction.point_hint": "Línia de construcció: feu clic en el punt per on ha de passar",
"ui.construction.reference_hint": "Línia de construcció: trieu una línia recta i després un punt; feu clic amb el botó dret per canviar la referència",
"ui.construction.ribbon_label": "Línies X",
@@ -5599,17 +5453,6 @@
"ui.ellipse": "El·lipse",
"ui.emboss_wrap_note": "Els perfils de línies ortogonals tangents a un cilindre compatible s'embolcallen automàticament. Trieu una paret per triar un destí.",
"ui.end": "Fi",
"ui.end_cap.chamfer": "Xamfrà de cantonada",
"ui.end_cap.direction": "Direcció",
"ui.end_cap.faces": "Cares d'extrem",
"ui.end_cap.inset": "Reducció",
"ui.end_cap.inset_kind": "Reduir per",
"ui.end_cap.inset_ratio": "Proporció de reducció",
"ui.end_cap.inward": "Cap endins",
"ui.end_cap.length": "Longitud",
"ui.end_cap.outward": "Cap enfora",
"ui.end_cap.ratio": "Proporció de gruix",
"ui.end_cap_title": "TAPA D'EXTREM",
"ui.end_radius": "Radi final",
"ui.end_scale": "Escala final",
"ui.ends": "Extrems",
@@ -5630,10 +5473,7 @@
"ui.explode_needs_two_components": "La vista explosionada separa components, així que el disseny en necessita dos o més (Component nou, Inserir un disseny o Crear components a partir de cossos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Les tecles, els favorits de la cerca S i la barra d'eines, com a fitxer",
"ui.export_selects_only_body": "Seleccionar l'únic cos en exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF i Enviar al laminador comencen amb el cos del disseny seleccionat quan només n'hi ha un.",
"ui.extend": "Estendre",
"ui.extent": "Extensió",
"ui.extent_type": "Tipus d'extensió",
@@ -5693,8 +5533,6 @@
"ui.finest": "Molt fina",
"ui.finish_sketch": "Acabar el bocet",
"ui.first_dimension_scales": "La primera cota escala el dibuix",
"ui.first_dimension_scales_about_origin": "Escalar respecte de l'origen del bocet",
"ui.first_dimension_scales_about_origin_tip": "Activat: el dibuix sempre creix o s'encongeix respecte de l'origen del bocet, així que una forma dibuixada lluny de l'origen es mou. Desactivat: un dibuix que res no lliga a l'origen s'escala respecte del seu propi centre i es queda on es va dibuixar.",
"ui.first_dimension_scales_tip": "Activat: la primera cota de longitud d'un bocet escala el dibuix que mesura, de manera que la seva forma no canvia. Desactivat: la cota només acota allò que mesura.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fixar/alliberar",
@@ -6117,7 +5955,6 @@
"ui.project_references_construction_note": "Projectar, Intersecar, Incloure geometria 3D i les arestes de cara d'un bocet nou sobre una cara creen geometria de construcció: referències sobre les quals dibuixar, excloses dels perfils. Continuen vinculades al model.",
"ui.properties": "Propietats",
"ui.properties.copy_matrix": "Copiar la matriu",
"ui.properties.copy_value": "Feu clic per copiar",
"ui.properties.first_moments": "Primers moments",
"ui.properties.inertia": "Moment d'inèrcia",
"ui.properties.inertia_note": "Respecte al centre de masses, en eixos globals (g·{unit}²)",
@@ -6130,7 +5967,6 @@
"ui.properties.measuring_failed": "El mesurament s'ha aturat abans d'acabar.",
"ui.properties.principal_axis": "Eix principal {number}",
"ui.properties.principal_moments": "Moments principals",
"ui.properties.value_copied": "Valor copiat",
"ui.pull_direction": "Direcció d'estirada",
"ui.quality": "Qualitat",
"ui.quantity": "Quantitat",
@@ -6151,14 +5987,11 @@
"ui.relink_design_changed": "L'ensamblatge ha canviat mentre trieu un origen. Torneu a iniciar Revincular l'origen.",
"ui.relink_done": "Origen revinculat a totes les instàncies.",
"ui.relink_not_linked": "Aquest component no està vinculat a cap disseny d'origen.",
"ui.relink_one_done": "Origen revinculat en aquesta instància.",
"ui.relink_select_one": "Seleccioneu primer un component vinculat o una de les seves instàncies.",
"ui.relink_source_all": "Revincular l'origen (totes les instàncies)…",
"ui.relink_source_one": "Revincular l'origen…",
"ui.removeFromQuickAccess": "Treure de l'accés ràpid",
"ui.removeFromToolbar": "Treure de la barra d'eines",
"ui.removeSection": "Treure la secció",
"ui.renameSelectedBodies": "Canviar el nom dels cossos seleccionats",
"ui.render": "Renderitzar",
"ui.renderer": "Motor de renderitzat",
"ui.repeat": "Repetir",
@@ -6189,8 +6022,6 @@
"ui.ribbon_pin_footer": "Les ordres fixades es mostren a la cinta. Passeu el punter per una fila per fixar-la o deixar-la de fixar.",
"ui.ribbon_workspace": "Espai de treball",
"ui.right": "Dreta",
"ui.right_click_confirms": "El botó dret confirma les entrades dels diàlegs",
"ui.right_click_confirms_tip": "Amb un diàleg obert, un clic amb el botó dret a la vista passa a la seva propera entrada buida, i a l'última executa OK",
"ui.save": "Desar",
"ui.save_as": "Desar com a",
"ui.save_mesh_no_slicer_open": "No s'ha trobat cap laminador: trieu Personalitzat… i trieu el vostre programa de laminatge.",
@@ -6250,16 +6081,6 @@
"ui.sketch_intersect_selection_filter": "Filtre de selecció",
"ui.sketch_keys_only": "Mantenir les tecles del model fora dels bocets",
"ui.sketch_keys_only_tip": "Mentre editeu un bocet, només s'executen les tecles de bocet i les que funcionen a tot arreu; una tecla de model com E o F no fa res, així una tecla perduda no pot tancar un bocet pesat.",
"ui.sketch_links_added_curve": "{command} ha afegit 1 corba al bocet.",
"ui.sketch_links_added_curves": "{command} ha afegit {count} corbes al bocet.",
"ui.sketch_links_added_curves_and_points": "{command} ha afegit {curves} i {points} al bocet.",
"ui.sketch_links_added_point": "{command} ha afegit 1 punt al bocet.",
"ui.sketch_links_added_points": "{command} ha afegit {count} punts al bocet.",
"ui.sketch_links_curve_count": "{count} corbes",
"ui.sketch_links_curve_count_one": "1 corba",
"ui.sketch_links_hidden": "El resultat s'amaga perquè {option} està desactivat a la paleta del bocet.",
"ui.sketch_links_point_count": "{count} punts",
"ui.sketch_links_point_count_one": "1 punt",
"ui.sketch_modifiers": "Modificadors del bocet",
"ui.sketch_project_geometry": "Geometria",
"ui.sketch_report.all_constrained": "Tot està totalment restringit.",
@@ -6299,7 +6120,6 @@
"ui.step_values": "Incrementar valors amb les fletxes i la roda",
"ui.step_values_tip": "En un camp de valor on esteu escrivint, Amunt/Avall canvien un nombre simple d'1 en 1 (Majúscules: 10, Alt: 0,1), i la roda del ratolí sobre el camp fa el mateix. Les expressions es deixen tal com s'escriuen.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Redreçar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermostreig (SSAA)",
"ui.supersampling_adaptive": "Només quan la vista està quieta",
@@ -6373,8 +6193,6 @@
"ui.view_angles_yaw": "Rumb",
"ui.view_angles_zero": "Posa {axis} a 0°",
"ui.view_centre": "Centre de la vista",
"ui.view_compass_tip": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar.",
"ui.view_compass_tip_snap": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar; manteniu Majúscules per ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} arestes",
+31 -170
View File
@@ -247,7 +247,6 @@
"Drag to move the hole; click to remove it": "Arrastra para mover el agujero; haz clic para quitarlo",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrastra para mover el refuerzo por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrastra para mover el encaje a presión por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to place it on another face.": "Arrastra para colocarlo en otra cara.",
"Drag to point the catch in another direction.": "Arrastra para orientar el retén en otra dirección.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrastra para girar el refuerzo en pasos de 5°. Mantén Alt o Ctrl para girarlo libremente.",
"Drive Joint": "Accionar unión",
@@ -261,8 +260,8 @@
"EMBOSS": "REALCE",
"EXPLODED VIEW": "VISTA EXPLOSIONADA",
"EXTEND": "EXTENDER",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada forma nueva también se acota desde el origen del boceto hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el boceto ya fija.",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada forma nueva también se acota desde el origen del croquis hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el croquis ya fija.",
"Edge": "Arista",
"Edges": "Aristas",
"Edit": "Editar",
@@ -514,7 +513,6 @@
"Offset Y": "Desfase Y",
"Offset Z": "Desfase Z",
"On your computer": "En tu equipo",
"One more stretch of the picked edge, each with its own angle, height and turns": "Otro tramo más de la arista seleccionada, cada uno con su propio ángulo, altura y giros",
"Opacity": "Opacidad",
"Open GitHub": "Abrir GitHub",
"Open GitHub draft": "Abrir borrador de GitHub",
@@ -636,7 +634,6 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Quitar este pliegue",
"Remove this point": "Eliminar este punto",
"Remove this segment": "Quitar este segmento",
"Rename Feature": "Cambiar nombre de la operación",
"Rename Folder": "Cambiar nombre de la carpeta",
"Render Stage": "Escenario de renderizado",
@@ -726,7 +723,6 @@
"Section position": "Posición de la sección",
"Sections": "Secciones",
"Sections (one sketch each)": "Secciones (un boceto cada una)",
"Segments": "Segmentos",
"Select Features": "Seleccionar operaciones",
"Select Other": "Seleccionar otro",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).",
@@ -776,7 +772,6 @@
"Spacing must be a non-zero number": "El espaciado debe ser un número distinto de cero",
"Specialise": "Especializar",
"Speed": "Velocidad",
"Split Edge": "Dividir arista",
"Splitting tool": "Herramienta de división",
"Square": "Cuadrado",
"Stainless Steel": "Acero inoxidable",
@@ -937,20 +932,13 @@
"ai.http.confirm": "Permitir para este servicio",
"ai.http.warning": "La clave de API y la conversación se enviarán sin cifrar. Cualquiera con acceso a la red podría leerlas o modificarlas. Úsalo solo en una red de confianza.",
"ai.prefs.allow_control": "Permitir que se conecten agentes de IA (canal de control / MCP)",
"ai.prefs.allow_control_note": "Un canal local (MCP). Nada sale de este equipo.\n\nAbre un canal de control solo en este equipo (127.0.0.1, en el puerto de abajo; 27878 toma el siguiente libre cuando está en uso). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica al momento. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.allow_control_note": "Abre un canal de control solo en este equipo (127.0.0.1, puerto 27878 o el siguiente libre). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica la próxima vez que se inicie SolidCraft. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.availability": "Controles del Asistente",
"ai.prefs.background_commands": "Ejecutar los comandos del agente en segundo plano",
"ai.prefs.connect_apps": "Poner SolidCraft a disposición de las aplicaciones de IA de este equipo",
"ai.prefs.connect_apps_note": "Al iniciar SolidCraft, lo añade como servidor MCP a las aplicaciones de IA que encuentre en este equipo (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), para que un agente pueda usarlo sin configurar nada. Solo se añade la entrada solidcraft o se actualiza su ruta; una entrada que hayas cambiado se deja intacta y el archivo se copia antes. Desactivado: se eliminan las entradas que añadió. Ayuda › Conectar asistentes de IA… lo hace a mano.",
"ai.prefs.connect_at_start": "Conectar el último agente usado al iniciar SolidCraft",
"ai.prefs.connect_at_start_note": "Inicia el agente que conectaste por última vez (o el predeterminado) en segundo plano cada vez que se abre SolidCraft",
"ai.prefs.control_failed": "No se ha podido iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciado con --control en el puerto {port} para esta ejecución. Los cambios de aquí se aplican la próxima vez que SolidCraft se inicie sin --control.",
"ai.prefs.control_listening": "Escuchando en 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No está escuchando",
"ai.prefs.control_port": "Puerto",
"ai.prefs.control_port_note": "El puerto al que se conectan los agentes de IA en este equipo (127.0.0.1). 27878 es el predeterminado: cuando está en uso, se toma el siguiente libre. Cualquier otro puerto se usa exactamente. Apunta tu cliente MCP al mismo puerto, por ejemplo solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El puerto {port} está en uso por otro programa (u otra ventana de SolidCraft). Elige otro puerto.",
"ai.prefs.follow_camera": "Girar la cámara hacia los bocetos",
"ai.prefs.follow_camera_off": "El seguimiento automático de la cámara está desactivado.",
"ai.prefs.follow_camera_on": "La cámara gira hacia los bocetos que crea el agente.",
@@ -1011,13 +999,6 @@
"browser.sketches_to_parts_not_on_face": "no en la cara de una pieza",
"browser.sketches_to_parts_part_missing": "falta su pieza",
"browser.sketches_to_parts_result": "Se movieron {moved} bocetos a la pieza en la que están; {stayed} se quedaron: {why}",
"browser.straighten_angle": "Dirección de la arista",
"browser.straighten_choose": "Elige Horizontal, Vertical o Ángulo para la arista y pulsa Enderezar.",
"browser.straighten_click_a": "Haz clic en la imagen en un extremo de una arista que deba quedar recta, o escribe sus coordenadas.",
"browser.straighten_click_b": "Haz clic en el otro extremo de la arista, o escribe sus coordenadas.",
"browser.straighten_horizontal": "Horizontal",
"browser.straighten_title": "Enderezar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unidades: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1047,8 +1028,6 @@
"cmd.appearance_pick_up": "Tomar apariencia",
"cmd.appearance_replace": "Reemplazar apariencia",
"cmd.assembly_bom": "Lista de materiales",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar restricción",
"cmd.assembly_mass_properties": "Propiedades de masa",
"cmd.body.print_face": "Cara de impresión",
"cmd.body_rename": "Cambiar nombre del cuerpo",
@@ -1067,7 +1046,6 @@
"cmd.canvas_delete": "Eliminar lienzo",
"cmd.canvas_edit": "Editar lienzo",
"cmd.canvas_insert": "Lienzo",
"cmd.canvas_straighten": "Enderezar lienzo",
"cmd.canvas_to_sketch": "Lienzo a boceto",
"cmd.component_activate": "Activar componente",
"cmd.component_break_link": "Romper vínculo",
@@ -1109,10 +1087,7 @@
"cmd.config_rule": "Regla de configuración",
"cmd.config_rules": "Reglas de configuración",
"cmd.config_table": "Mostrar tabla de configuraciones",
"cmd.construct.axis": "Eje",
"cmd.construct.plane": "Plano",
"cmd.construct.plane.axis_point": "Plano por eje y punto",
"cmd.construct.point": "Punto",
"cmd.construct_axis_edge": "Eje por arista",
"cmd.construct_axis_normal_to_face": "Eje perpendicular a cara",
"cmd.construct_axis_through_cylinder": "Eje por cilindro/cono/toro",
@@ -1250,10 +1225,6 @@
"cmd.motion_trace": "Trayectoria de movimiento",
"cmd.motion_trace_edit": "Editar traza de movimiento",
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
"cmd.note_add": "Añadir nota",
"cmd.note_delete": "Eliminar nota",
"cmd.note_list": "Listar notas",
"cmd.note_update": "Editar nota",
"cmd.occurrence_align": "Alinear instancia",
"cmd.occurrence_copy": "Pegar (instancia)",
"cmd.occurrence_drag": "Arrastrar instancia",
@@ -1379,7 +1350,6 @@
"cmd.render_start": "Renderizar imagen",
"cmd.render_status": "Progreso del renderizado",
"cmd.select_clear": "Borrar selección",
"cmd.selection_get": "Obtener selección",
"cmd.settings_profile_apply": "Aplicar perfil de ajustes",
"cmd.settings_profile_delete": "Eliminar perfil de ajustes",
"cmd.settings_profile_export": "Exportar perfil de ajustes",
@@ -1543,7 +1513,6 @@
"cmd.timeline_suppress": "Suprimir operación",
"cmd.ui.assistant": "Alternar Asistente",
"cmd.ui.palette": "Buscar comandos",
"cmd.ui.toggle_visibility": "Mostrar/ocultar selección",
"cmd.view.navigator": "Navegador de vistas personalizado",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Mover acople de render",
@@ -1601,7 +1570,6 @@
"color.step_down": "Bajar un paso",
"color.step_up": "Subir un paso",
"color.value": "Valor, el brillo (porcentaje)",
"component.error.ambiguous_name": "Varios componentes se llaman `{name}`. Usa un ID numérico de componente: {ids}.",
"config.column_filter_empty": "Sin columnas coincidentes",
"config.rule_parameter_columns": "Solo se listan los parámetros con una columna en la tabla. Añade una con Añadir columna (+) para usarla aquí.",
"config.rule_suppression_columns": "Solo se listan las operaciones con una columna Suprimir en la tabla. Añade una con Añadir columna (+) para controlarla aquí.",
@@ -1642,7 +1610,6 @@
"diagnostic.reselect_reference": "{feature}: la referencia `{reference}` ya no existe como se seleccionó; vuélvela a seleccionar",
"dialog.bodies_count": "{count} cuerpos",
"dialog.clearance_invalid": "la holgura debe ser una longitud de 0 o más",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nuevo",
"dialog.computing": "Calculando… {seconds} s",
@@ -1655,7 +1622,6 @@
"dialog.dimension_step_expression": "Solo se puede avanzar con un número; una expresión se queda como se escribe",
"dialog.dimension_step_up": "Subir {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "La arista de referencia mantiene este agujero sobre una línea que sale de la cara antes del punto seleccionado. Elige dónde cruza la línea la cara, o quita la arista para mover el agujero libremente.",
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
@@ -1674,12 +1640,9 @@
"document.changed_on_disk": "{name} ha cambiado en el disco",
"document.changed_on_disk_detail": "Otro programa u otro equipo ha modificado {name} desde que lo abriste o lo guardaste por última vez. Sobrescribir sustituye esos cambios por los tuyos; Guardar una copia conserva ambos.",
"document.copy_name": "Copia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Termina de responder al aviso de guardado antes de abrir otro diseño.",
"document.newer_file": "Guardado con una versión más reciente de SolidCraft (formato {format}; esta versión lee hasta {reads}). Si falta algo, actualiza.",
"document.newer_file_change": "Guardado con una versión más reciente de SolidCraft (formato {format} con `{change}`, que esta versión no conoce). Si falta algo, actualiza.",
"document.newer_file_not_read": "No leído: {what}. Al guardar se conserva.",
"document.not_saved_yet": "Aún sin guardar",
"document.open_anyway": "Abrir de todos modos",
"document.open_elsewhere": "{name} ya está abierto",
"document.open_elsewhere_detail": "{name} está abierto en SolidCraft {app} en {who}, abierto {when}. Si guardáis los dos, uno de vosotros perderá su trabajo. Ábrelo en solo lectura para verlo sin sobrescribirlo.",
@@ -1689,9 +1652,6 @@
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
"document.save_changes": "¿Guardar los cambios de {name}?",
"document.save_copy": "Guardar una copia…",
"document.save_many": "¿Guardar los cambios de {n} diseños?",
"document.save_row": "Guardar",
"document.save_selected": "Guardar seleccionados",
"document.tab_unsaved_changes": "Cambios sin guardar",
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
@@ -1724,7 +1684,6 @@
"file_load.inserting": "Insertando geometría",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "Se ha abierto {name}",
"file_load.opened_behind": "{name} se ha abierto en una pestaña nueva",
"file_load.opened_in_tab": "Se ha abierto {name} en una pestaña nueva",
"file_load.opening": "Abriendo diseño",
"file_load.preparing_thumbnail": "Preparando la miniatura...",
@@ -1772,28 +1731,25 @@
"help.config.table": "Muestra la tabla de configuración: una fila por variante, una columna por parámetro u operación, y si el diseño sigue coincidiendo con la fila activa. Úsalo para revisar las variantes de un diseño.",
"help.connect_note": "Reinicia cada asistente para que cargue el servidor de SolidCraft. Cada archivo se guardó como .bak antes de modificarlo.",
"help.connect_title": "Conectar asistentes de IA",
"help.construct.axis": "Crea un eje de construcción con el método que pidan tus selecciones: a lo largo de una arista o línea, por un agujero o una cara redonda, donde se encuentran dos planos, por dos puntos, o perpendicular a una cara por un punto. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.axis.edge": "Crea un eje de construcción a lo largo de una arista recta o de una línea de boceto. Úsalo como eje de una revolución, un patrón circular o una unión.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como eje que sale recto de una cara.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como un eje que sale recto de una superficie.",
"help.construct.axis.through_cylinder": "Crea un eje de construcción por el centro de una cara cilíndrica, cónica o toroidal, o de una arista circular. Úsalo para girar o repetir operaciones alrededor de una pieza redonda.",
"help.construct.axis.two_planes": "Crea un eje de construcción donde se cortan dos planos o caras planas. Úsalo cuando el eje que necesitas sea la línea entre dos caras.",
"help.construct.axis.two_points": "Crea un eje de construcción por dos puntos, vértices o puntos de boceto. Úsalo para un eje que va entre dos posiciones conocidas.",
"help.construct.plane": "Crea un plano de construcción con el método que pidan tus selecciones: con desfase respecto a un plano, por un punto, entre dos planos, por líneas o puntos, tangente a una cara curva, o perpendicular a una trayectoria. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.plane.along_path": "Crea un plano de construcción perpendicular a una arista o curva, en una posición a lo largo de ella. Úsalo para dibujar el perfil de un barrido o una sección de una trayectoria.",
"help.construct.plane.at_angle": "Crea un plano de construcción girado un ángulo respecto a un plano o cara, alrededor de un eje o arista. Activa «Usar solo la dirección del eje» para proyectar el eje sobre la base y pivotar alrededor de un punto de ese plano (el origen de la base cuando no se elige ninguno). Úsalo para bocetos inclinados, como un soporte angular.",
"help.construct.plane.axis_point": "Crea un plano de construcción que contiene un eje y un punto fuera de ese eje. Elige un origen o un eje de construcción, una arista recta o una línea de boceto, y después un vértice, un punto de boceto o un punto de construcción. Un punto del eje no define un plano único.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría al reflejar.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría para un espejo.",
"help.construct.plane.offset": "Crea un plano de construcción paralelo a un plano o una cara plana, a una distancia o por un punto. Úsalo para dibujar a una altura fija sobre una cara.",
"help.construct.plane.perpendicular": "Crea un plano de construcción que contiene una línea o arista y es perpendicular a un plano o una cara. Úsalo para dibujar en vertical sobre un modelo a lo largo de una arista.",
"help.construct.plane.tangent": "Crea un plano de construcción que toca un cilindro, cono, esfera o toro en un punto. Úsalo para dibujar en el lado de una pieza redonda.",
"help.construct.plane.three_points": "Crea un plano de construcción por tres puntos. Úsalo para un plano definido por posiciones del modelo, como tres esquinas.",
"help.construct.plane.two_edges": "Crea un plano de construcción por dos aristas rectas o líneas que están en un mismo plano. Úsalo para un plano que cruza una pieza, por ejemplo por dos aristas opuestas.",
"help.construct.point": "Crea un punto de construcción con el método que pidan tus selecciones: en un vértice, en el centro de un círculo o una esfera, a lo largo de una arista, donde se encuentran dos líneas o tres planos, o donde una línea cruza un plano. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.point.along_path": "Crea un punto de construcción en una posición a lo largo de una arista o curva. Úsalo como referencia a mitad de una trayectoria.",
"help.construct.point.center": "Crea un punto de construcción en el centro de una arista circular, un círculo o arco de boceto, una esfera o un toro. Úsalo como referencia para agujeros, ejes y uniones.",
"help.construct.point.edge_plane": "Crea un punto de construcción donde una arista o línea atraviesa un plano o una cara plana. Úsalo para marcar dónde una trayectoria cruza un plano.",
"help.construct.point.three_planes": "Crea un punto de construcción donde se encuentran tres planos o caras planas. Úsalo como referencia de esquina, incluso donde el modelo no tiene vértice.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde más se acercan. Úsalo como referencia en una esquina virtual.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde están más cerca. Úsalo como referencia en una esquina virtual.",
"help.construct.point.vertex": "Crea un punto de construcción sobre un vértice o un punto de boceto. Úsalo como referencia que permanece cuando se oculta el boceto o el cuerpo que lo rodea.",
"help.contact.check": "Lista los pares de piezas de los conjuntos de contacto que se atraviesan en su posición actual. Úsalo para encontrar colisiones antes de accionar uniones o ejecutar un estudio de movimiento.",
"help.contact.create": "Agrupa dos o más piezas en un conjunto de contacto, cuyos cuerpos no pueden atravesarse mientras las uniones se mueven. Úsalo para piezas que deben detenerse al tocarse, como una palanca contra su tope.",
@@ -1815,7 +1771,7 @@
"help.font_details": "La interfaz utiliza las fuentes integradas de egui (Ubuntu Light y Hack, bajo las licencias Ubuntu Font Licence y MIT/Bitstream Vera; Noto Emoji y la fuente de iconos emoji, bajo OFL y MIT). El texto de los bocetos utiliza Liberation Sans (SIL OFL 1.1). El paquete opcional de fuentes de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no se incluye en las compilaciones de SolidCraft.",
"help.fonts": "Fuentes",
"help.frame.end_cap": "Cierra los extremos abiertos de los miembros de una estructura con placas con la forma del contorno del perfil. Úsalo para tapar tubos, angulares y vigas, sobre el extremo o embutidas en él.",
"help.frame.member": "Coloca un perfil, como un tubo o una extrusión de aluminio, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.frame.member": "Coloca un perfil, como un tubo o un perfil de aluminio extruido, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.inspect.accessibility": "Colorea las caras a las que una herramienta puede llegar desde una dirección y las que no. Úsalo para comprobar una pieza para mecanizado o moldeo desde ese lado.",
"help.inspect.center_of_mass": "Muestra el centro de masa de los cuerpos, cada uno por separado y todos juntos, con densidad uniforme. Úsalo para comprobar el equilibrio y dónde volcará una pieza.",
"help.inspect.colored_components": "Da a cada componente su propio color temporal, sin cambiar los aspectos. Úsalo para distinguir de un vistazo las piezas de un ensamblaje.",
@@ -1886,11 +1842,11 @@
"help.parts.library": "Abre las piezas estándar: tornillos, pernos, tuercas, arandelas, tornillos sin cabeza, pasadores cilíndricos y rodamientos en tamaños estándar. Úsalo para añadir una pieza estándar en lugar de modelarla tú.",
"help.parts.resize": "Cambia el tamaño o la longitud de una pieza estándar insertada y la reconstruye; sus uniones la mantienen asentada. Úsalo cuando un tornillo resulta demasiado largo o un agujero necesita otro tamaño.",
"help.plastic.assign_rule": "Asigna una regla de plástico a cuerpos, de modo que sus operaciones de plástico toman su desmoldeo y holgura como valores por defecto. Úsalo para configurar una pieza moldeada una sola vez para todas sus operaciones.",
"help.plastic.boss": "Añade un saliente para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.boss": "Añade una torreta para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
"help.plastic.manage_rules": "Crea y edita reglas de plástico: material, espesor de pared, radios, holgura y desmoldeo. Úsalas para mantener coherentes las operaciones de una pieza moldeada.",
"help.plastic.rest": "Añade un apoyo en una cara: un taco redondo o rectangular, macizo o hueco, con desmoldeo. Úsalo como soporte o separador en una carcasa moldeada.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y retención. Úsalo para unir piezas sin tornillos.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y resalte. Úsalo para unir piezas sin tornillos.",
"help.pointcloud.insert": "Inserta un escaneo PLY, XYZ o PTS como nube de puntos junto al modelo. Úsala como",
"help.sheet.convert": "Convierte una placa o un cuerpo vaciado de espesor uniforme en un cuerpo de chapa metálica, con pliegues en las aristas que elijas. Úsalo para hacer desplegable una pieza existente.",
"help.sheet.export_bend_table": "Guarda la tabla de plegado de un cuerpo de chapa metálica como CSV o JSON: ángulos, radios y posiciones de los pliegues en el desarrollo. Úsalo para la plegadora o el taller.",
@@ -1901,22 +1857,22 @@
"help.sheet.hem": "Dobla el borde de una chapa sobre sí mismo. Úsalo para bordes seguros y rígidos, sin rebabas cortantes.",
"help.sheet.manage_rules": "Crea y edita reglas de chapa metálica: espesor, radio de plegado, factor K, alivios y holguras. Úsalas para ajustarte al material y a las herramientas de tu taller.",
"help.sheet.path_flange": "Añade una cadena de pestañas a lo largo del borde de una chapa, siguiendo una cadena abierta de líneas de boceto. Úsalo para perfiles como una U o una Z en un solo paso.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o en cuadrícula. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o cuadrado. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.refold": "Vuelve a plegar un cuerpo de chapa metálica desplegado. Úsalo tras añadir operaciones a la chapa plana.",
"help.sheet.unfold": "Despliega todos los pliegues de un cuerpo de chapa metálica. Úsalo para añadir agujeros o cortes que deban cruzar un pliegue, y luego vuelve a plegar.",
"help.sketch.arc.center_point": "Dibuja un arco circular alrededor de un punto central, de un punto inicial a un punto final. Úsalo cuando se conoce el centro del arco, como un radio alrededor de un agujero o de un pivote.",
"help.sketch.arc.tangent": "Dibuja un arco que empieza en el extremo de una línea o arco y continúa tangente a él. Úsalo para seguir una cadena con una curva suave en lugar de una esquina.",
"help.sketch.arc.three_point": "Dibuja un arco de un punto inicial a un punto final pasando por un tercer punto. Úsalo cuando el arco deba pasar por puntos dados y no alrededor de un centro conocido.",
"help.sketch.arc3d": "Dibuja un arco circular exacto en el espacio por tres puntos, con el Boceto 3D activado. Úsalo para curvas en trayectorias y guías que salen del plano del boceto.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar en un solo paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restringir automáticamente, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar de un paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restricción automática, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.blend_curve": "Une los extremos de dos curvas con una spline suave tangente a ambas, o también de curvatura continua si lo eliges. Úsalo para salvar un hueco entre curvas sin quiebros.",
"help.sketch.break": "Divide una curva en trozos donde otras curvas la cruzan, o en puntos que colocas sobre ella. Úsalo cuando una parte de una curva necesite su propia restricción o deba borrarse por separado.",
"help.sketch.cancel": "Sale del boceto y descarta todo lo cambiado desde que se creó o se abrió para editarlo. Deshacer recupera los cambios.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, una simetría o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definida por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, un espejo o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definido por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.chamfer.equal_distance": "Bisela la esquina entre dos líneas la misma distancia hacia atrás en ambas. Úsalo para el chaflán habitual de 45° en un contorno.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definida por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definido por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.circle.center": "Dibuja un círculo a partir de su centro y su diámetro. Úsalo para agujeros, salientes y otras formas redondas cuyo centro se conoce.",
"help.sketch.circle.three_point": "Dibuja un círculo por tres puntos. Úsalo cuando el círculo deba pasar por puntos dados, por ejemplo de geometría existente.",
"help.sketch.circle.three_tangent": "Dibuja un círculo que toca tres líneas, círculos o arcos. Úsalo para ajustar un círculo en el espacio entre tres curvas, como el círculo inscrito de un triángulo.",
@@ -1941,13 +1897,13 @@
"help.sketch.construction_line": "Crea una línea de construcción infinita a través de dos puntos. Úsala para organizar un boceto.",
"help.sketch.construction_line.angle": "Crea una línea de construcción infinita en un ángulo desde el eje X del boceto. Elige el punto a",
"help.sketch.construction_line.bisector": "Elige dos líneas rectas de boceto que se intersecten, luego elige una de las dos bisectrices del ángulo.",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita que pase por un punto, o escribe su distancia al eje X del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción horizontal sigue las ediciones posteriores.",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto y luego coloca una línea de construcción paralela infinita o escribe su desfase. Un desfase escrito se convierte en una distancia controladora. Haz clic con el botón derecho para elegir otra referencia.",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita a través de un punto. Su dirección horizontal",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto, luego coloca una línea de construcción paralela infinita. R",
"help.sketch.construction_line.perpendicular": "Elige una línea recta de boceto, luego coloca una línea de construcción perpendicular infinita.",
"help.sketch.construction_line.tangent_at": "Crea una tangente en un punto de un círculo o arco circular, o en un extremo de una curva abierta.",
"help.sketch.construction_line.tangent_external": "Elige un círculo o arco circular, luego un punto externo y una solución de tangente. Th",
"help.sketch.construction_line.tangent_two": "Elige dos círculos o arcos circulares, luego elige una tangente común en la previsualización.",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita que pase por un punto, o escribe su distancia al eje Y del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción vertical sigue las ediciones posteriores.",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita a través de un punto. Su dirección vertical",
"help.sketch.copy": "Copia las curvas y puntos de boceto seleccionados, junto con las restricciones y cotas entre ellos. Pégalos en este boceto o en otro.",
"help.sketch.create": "Empieza un nuevo boceto 2D en un plano o en una cara plana del modelo. Toda extrusión, revolución y barrido empieza con un boceto.",
"help.sketch.cut": "Copia la geometría de boceto seleccionada y la borra del boceto, en un solo paso de deshacer. Pégala en otro lugar de este boceto o en otro.",
@@ -1958,10 +1914,10 @@
"help.sketch.extend": "Alarga una línea o un arco hasta la curva más cercana que encuentre, o hace crecer dos curvas hasta que se encuentran. Úsalo para cerrar huecos de un perfil tras recortar o editar.",
"help.sketch.fillet": "Redondea la esquina donde se encuentran dos líneas o arcos con un arco de un radio dado. Varias esquinas redondeadas a la vez comparten un radio.",
"help.sketch.finish": "Sale del boceto y vuelve al modelo. Sus perfiles cerrados quedan listos para Extrusión, Revolución y otras operaciones.",
"help.sketch.finish_auto_constrain": "Ejecuta Restringir automáticamente en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido en un solo paso.",
"help.sketch.finish_auto_constrain": "Ejecuta Restricción automática en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido de un paso.",
"help.sketch.fit_curves_to_section": "Corta un cuerpo de malla o sólido con el plano del boceto y ajusta líneas y arcos a la sección. Úsalo para reconstruir como boceto editable una pieza escaneada o importada.",
"help.sketch.include_3d": "Trae aristas del modelo, vértices y curvas de otros bocetos a este boceto como referencias vinculadas, aplanando sobre él las que no están en su plano. Úsalo para construir sobre geometría que no está en el plano del boceto.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan todo el visor. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan toda la vista. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.insert_dxf": "Lee las curvas de un dibujo DXF en un boceto, escaladas, giradas y colocadas donde indiques. Úsalo para construir sobre un dibujo 2D de otro programa, como un contorno para corte láser.",
"help.sketch.insert_svg": "Lee los trazados de un dibujo SVG en un boceto, escalados, girados y colocados donde indiques. Úsalo para convertir un logotipo o un contorno vectorial en curvas que puedas extruir.",
"help.sketch.inspect": "Abre un informe sobre el boceto abierto: cuántos grados de libertad quedan y qué",
@@ -2066,12 +2022,12 @@
"help.view.apply": "Gira la vista a una dirección estándar como frontal, superior o isométrica, o a una vista con nombre guardada. Úsalo para mirar el modelo desde un ángulo fijo.",
"help.view.delete": "Borra una vista con nombre guardada. El modelo no cambia.",
"help.view.fit": "Acerca y desplaza para que todo el modelo, o la selección, llene la vista. Úsalo cuando el modelo se ha salido de la vista.",
"help.view.grid": "Muestra u oculta la cuadrícula en el visor. Úsala como guía de tamaño y orientación al modelar.",
"help.view.grid": "Muestra u oculta la cuadrícula en la vista. Úsala como guía de tamaño y orientación al modelar.",
"help.view.ground_shadow": "Muestra u oculta la sombra del modelo en el suelo. Úsalo para juzgar con más facilidad la altura y la posición.",
"help.view.home": "Devuelve la vista a la dirección de inicio y encuadra el modelo. Úsalo para volver rápido a una vista conocida.",
"help.view.list": "Lista las vistas con nombre guardadas en el diseño. Úsalo para encontrar una vista que guardaste antes.",
"help.view.navigate": "Gira, desplaza o acerca la vista por pasos, como un cuarto de vuelta a la izquierda. Úsalo para mover la cámara con precisión en lugar de arrastrar.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior del visor. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior de la vista. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.rename": "Da un nombre nuevo a una vista con nombre guardada. La vista en sí no cambia.",
"help.view.ribbon_tabs_only": "Pliega la cinta hasta su fila de pestañas para que la vista recupere el espacio de los paneles. Haz clic en una pestaña para mostrar sus paneles sobre la vista; vuelve a elegir esta opción para mantener toda la cinta visible.",
"help.view.save": "Guarda la posición actual de la cámara como una vista con nombre en el diseño. Úsalo para volver al mismo ángulo más tarde, por ejemplo para capturas de pantalla.",
@@ -2291,9 +2247,6 @@
"measure.at": "En",
"measure.item": "Elemento {number}",
"measure.line_to_centre": "Línea al centro",
"measure.perimeter": "Perímetro",
"measure.perimeter_inner": "Perímetro interior",
"measure.perimeter_outer": "Perímetro exterior",
"measure.quick_copied": "Medición copiada",
"measure.quick_copy": "Haz clic para copiar la medición",
"measure.quick_edges": "{count} aristas",
@@ -2335,8 +2288,6 @@
"nav.hfss.help": "Alt+arrastre izq. desplaza, Mayús+Alt+arrastre izq. hace zoom, Ctrl+arrastre izq. en espacio vacío orbita",
"nav.inventor.help": "Arrastrar con el botón central desplaza, Mayús+central orbita, Ctrl+central hace zoom y arrastrar con el derecho orbita",
"nav.maya.help": "Alt+arrastre izq. orbita, Alt+arrastre central desplaza, Alt+arrastre der. hace zoom; arrastre central también desplaza",
"nav.maya_horizontal_zoom.help": "Alt+arrastrar con el botón derecho: hacia la derecha acerca, hacia la izquierda aleja. Desactívalo para volver al zoom arrastrando en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrastrando en horizontal",
"nav.nx.help": "Arrastrar con botón central orbita, Shift+central desplaza (central + derecho de NX), Ctrl+central hace zoom (central + izquierdo de NX); arrastrar con derecho deja la cámara",
"nav.openscad.help": "Arrastrar con el botón izquierdo sobre un espacio vacío orbita, Mayús+izquierdo selecciona con un recuadro y arrastrar geometría del boceto la edita; arrastrar con el derecho o el central desplaza, y Mayús+derecho o Mayús+central hace zoom",
"nav.orbit_cursor_wrap.help": "Un arrastre de órbita que llega al borde de la vista mueve el cursor al borde opuesto y sigue girando. Desactivado: el arrastre se detiene en el borde de la pantalla. No tiene efecto en Wayland ni en el navegador.",
@@ -2467,16 +2418,6 @@
"not a move": "no es un movimiento",
"not a number": "no es un número",
"open a sketch first": "abre primero un boceto",
"operations.cancel": "Cancelar {name}",
"operations.cancelled": "{name} cancelado",
"operations.cancelling": "Cancelando…",
"operations.cannot_cancel": "{name} no se puede cancelar",
"operations.feature": "Operación",
"operations.feature_edit": "Editar operación",
"operations.meshing": "Cargando piezas",
"operations.queued": "En cola",
"operations.stopped": "Cancelado",
"operations.waiting": "Listo",
"parameters.apply_live": "Aplicar en vivo",
"parameters.apply_live_tip": "Aplica los cambios de parámetros mientras los editas",
"parameters.held": "Mantenidos ({count})",
@@ -2565,7 +2506,6 @@
"prefs.appearance.viewport_colours": "Colores de vista 3D",
"prefs.appearance.viewport_logo": "Visor y logo",
"prefs.applies_on_restart": "Se aplica al reiniciar",
"prefs.assemblies": "Ensamblajes",
"prefs.assistant": "Asistente",
"prefs.assistant_connecting": "Conectando",
"prefs.assistant_follow_help": "Los bocetos Mirar mueven la vista con el puntero.",
@@ -2618,10 +2558,6 @@
"prefs.choose_font": "Elegir archivo de fuente…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diálogos de comando",
"prefs.constraint_glyph_size": "Tamaño de los glifos de restricción",
"prefs.constraint_glyph_size_note": "Cómo de grandes se dibujan los glifos de restricción y sus letras o iconos en un boceto. El 100 % es el tamaño habitual; los glifos conservan este tamaño en pantalla con cualquier zoom.",
"prefs.construct_menu_groups": "Agrupar las herramientas de Construir por tipo de entidad",
"prefs.construct_menu_groups_help": "Muestra los encabezados Planos, Ejes y Puntos. Desactivado, se mantiene el orden original del menú.",
"prefs.contact_shading": "Sombreado de contacto suave",
"prefs.contact_shading_note": "Sombreado suave donde las piezas se acercan.",
"prefs.context_menu": "Menú contextual",
@@ -2653,12 +2589,10 @@
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivado",
"prefs.design_tools.placement_group": "Apoyo, Saliente, Encaje",
"prefs.design_tools.placement_group": "Apoyo, Resalte, Encaje",
"prefs.design_tools.rows": "Filas",
"prefs.devices": "Ratón 3D",
"prefs.dialog_buttons": "Botones de diálogo",
"prefs.dialog_first_letter_buttons": "Los botones de diálogo responden a su primera letra",
"prefs.dialog_first_letter_buttons_help": "Con un diálogo abierto, escribir la primera letra de la etiqueta de un botón lo pulsa: O para OK, C para Cancelar. Una letra que encaja en dos botones no hace nada, y un campo de texto con el foco conserva la pulsación. Desactivado: escribir no afecta a los botones, como antes.",
"prefs.dialogs": "Diálogos",
"prefs.dialogs_edge": "En el borde de la vista",
"prefs.dialogs_left_column": "En la columna izquierda",
@@ -2672,23 +2606,12 @@
"prefs.dimension_values_window": "En una ventana",
"prefs.display": "Pantalla",
"prefs.edges": "Aristas y sombreado",
"prefs.export": "Exportar",
"prefs.export_name": "Nombre del archivo de exportación",
"prefs.export_name.body": "Nombre del cuerpo exportado (el primero cuando hay varios)",
"prefs.export_name.design": "Nombre del diseño",
"prefs.export_name_hint": "p. ej., {design}-{body}",
"prefs.export_name_sample": "Al exportar este diseño se ofrece: {name}",
"prefs.export_name_tip": "El nombre de archivo que ofrecen Archivo › Exportar y Guardar como malla. Se sustituyen estos marcadores:\n{placeholders}\nUna propiedad del diseño que no esté definida se omite. Deja el campo vacío para ofrecer el nombre del diseño o, en una malla, el del cuerpo.",
"prefs.extend": "Extensiones",
"prefs.extra_navigation": "Ctrl + arrastrar con el botón izquierdo sobre un espacio vacío orbita; mantén Espacio para desplazar.",
"prefs.extrude_arrow_face_stops": "La flecha de Extruir se detiene en las caras",
"prefs.extrude_arrow_face_stops_note": "Al arrastrar la flecha de distancia de Extruir, se detiene en las caras planas paralelas al perfil, como el lado opuesto de un bloque que se corta, y resalta la cara. Desactivado: la flecha solo avanza en pasos redondos.",
"prefs.extrude_view_isometric": "Girar la vista a isométrica al abrir Extrusión",
"prefs.extrude_view_return": "Volver a la vista anterior al cerrar Extrusión",
"prefs.file_operations_wait": "Esperar a que se abran los archivos",
"prefs.file_operations_wait_note": "Mientras se abre o se inserta un archivo, o se vuelven a leer los vínculos, el diseño espera tras una tarjeta con el progreso del archivo y Cancelar. Desactivado: el archivo aparece en el panel de operaciones y puedes seguir trabajando; una inserción se aplica cuando termines lo que estás haciendo, y un archivo abierto mientras tanto va a una pestaña nueva.",
"prefs.fillet_legacy_radius": "Usar el radio predeterminado anterior de Redondeo de sólido",
"prefs.fillet_legacy_radius.help": "Desactivado de forma predeterminada. Activado, un nuevo Redondeo de sólido se abre con el número 2 leído en las unidades del diseño («2 in» en un diseño en pulgadas), como antes; desactivado, se abre con el radio que trae la herramienta, 2 mm, mostrado en las unidades del diseño («0.07874 in» en un diseño en pulgadas).",
"prefs.follows_os": "Sigue al sistema operativo",
"prefs.font": "Fuente",
"prefs.font_fallback": "Los glifos que faltan en la fuente elegida se toman de la fuente integrada, así que ninguna etiqueta muestra un recuadro vacío.",
@@ -2766,8 +2689,6 @@
"prefs.history_sketches_in_order": "En orden",
"prefs.history_sketches_note": "Con Bocetos en Debajo de su operación, el Historial de la izquierda lista cada boceto un nivel dentro, bajo la primera operación construida a partir de él, siempre que haya ancho para nombres. La línea de tiempo en la parte inferior mantiene siempre el orden de creación.",
"prefs.history_sketches_under_feature": "Debajo de su operación",
"prefs.history_steps_component": "Pasos del historial por componente",
"prefs.history_steps_component_note": "Con un componente activo o seleccionado, los controles del Historial recorren las operaciones de ese componente, tal como las muestra el Historial de abajo. Desactivado: recorren todas las operaciones del diseño. El Historial de la izquierda o bajo el navegador siempre recorre todas las operaciones.",
"prefs.hold_stop_snapping": "Mantén pulsado para desactivar el ajuste",
"prefs.home_fit_size": "Tamaño de Inicio y Ajustar",
"prefs.home_fit_size_note": "Cuánto de la vista ocupa el modelo al usar Inicio, Ajustar, un diseño abierto o un cambio de vista del navegador, independientemente de la configuración siguiente. 100 %: llena la vista entre los paneles, la brújula y la barra de navegación.",
@@ -2783,8 +2704,6 @@
"prefs.invert_curved_roll_arrows_note": "Las dos flechas curvas en la esquina del cubo giran la vista en el plano de la pantalla. Activado: la flecha horaria gira antihoraria y la antihoraria gira horaria. Desactivado: cada flecha gira la vista hacia donde se curva.",
"prefs.invert_navigator_arrows": "Invertir las flechas de rotación del cubo de vista",
"prefs.invert_navigator_arrows_note": "Las flechas alrededor del cubo orbitan la cámara hacia donde apuntan, como en Fusion e Inventor: la flecha de la izquierda apunta a la derecha y gira la cámara a la derecha. Desactivado: una flecha rota el cubo hacia el lado en que está, así la flecha izquierda gira la cámara a la izquierda. Las flechas de giro curvo no se ven afectadas.",
"prefs.joint_drag_trackball": "Girar las piezas de una unión de rótula como un trackball al arrastrarlas",
"prefs.joint_drag_trackball_help": "Arrastrar una pieza que sujeta una unión de rótula la gira con el menor giro posible alrededor de la unión, como si rodara una bola del tamaño de la pieza. Desactivado: el punto que agarraste sigue exactamente al puntero, y la pieza también puede girar sobre él.",
"prefs.keep_unsaved_also_asks": "Preguntar igualmente al cerrar la pestaña",
"prefs.keep_unsaved_also_asks_tip": "Con esto desactivado, la pestaña se cierra sin el cuadro del navegador en cuanto la copia de cada diseño sin guardar está al día.",
"prefs.keep_unsaved_in_browser": "Conservar el trabajo sin guardar en este navegador",
@@ -2860,8 +2779,6 @@
"prefs.not_on_gpu": "no en esta GPU",
"prefs.on": "Activado",
"prefs.opaque": "Opaco",
"prefs.operations_panel": "Mostrar las operaciones en la vista",
"prefs.operations_panel_note": "Las operaciones largas (abrir un archivo, calcular una operación, un comando del agente) se muestran en la esquina inferior derecha de la vista con su progreso y un botón para cancelarlas. Desactivado: un aviso de «Calculando», como antes.",
"prefs.orbit_snap": "Mantén Mayús mientras orbitas para ajustar a un eje.",
"prefs.orientation": "Orientación",
"prefs.origin_plane_names": "Nombres de los planos de origen",
@@ -2942,8 +2859,6 @@
"prefs.ribbon_tabs_only": "Mostrar solo las pestañas de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta se pliega hasta su fila de pestañas. Al hacer clic en una pestaña se muestran sus paneles sobre la vista hasta que empieza un comando o el puntero sale de ellos. «Solo pestañas de la cinta», en la pestaña Vista o con su atajo, alterna esta opción.",
"prefs.right_click": "Clic derecho",
"prefs.save_prompt_one_at_a_time": "Preguntar por cada diseño sin guardar por separado al cerrar varios",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un aviso de guardado por cada diseño sin guardar en lugar de una lista combinada.",
"prefs.saving": "Guardado automático y recuperación",
"prefs.scale_note": "También Ctrl + / Ctrl − en la ventana. Los botones miden 28 px al 100%.",
"prefs.scale_text": "Escala y texto",
@@ -2980,8 +2895,8 @@
"prefs.sketch.center_arc": "El arco por centro sigue al cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicto",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un boceto",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del boceto, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un croquis",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del croquis, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.drawing": "Dibujo",
"prefs.sketch.ellipse_axes": "Dibujar elipses con sus ejes",
"prefs.sketch.ellipse_axes_note": "Los ejes mayor y menor como líneas de construcción, para poder acotar los semiejes y los ejes. Desactivado: solo la elipse, y al pulsar D sobre ella se da su radio menor allí donde se arrastre el valor, dibujado y etiquetado como R, como antes.",
@@ -2993,20 +2908,14 @@
"prefs.sketch.faces_edges": "Caras y aristas",
"prefs.sketch.hide_glyphs": "Ocultar glifos al alejar",
"prefs.sketch.marks": "Marcas en el boceto",
"prefs.sketch.max_points": "Boceto más grande",
"prefs.sketch.max_points_note": "El máximo de puntos o curvas que admite un boceto: 20000 de forma predeterminada, como mucho 200000. Insertar SVG e Insertar DXF rechazan un dibujo mayor. Un boceto grande se resuelve y se dibuja más despacio.",
"prefs.sketch.max_points_unit": "puntos",
"prefs.sketch.note": "Dibujo, ajuste y selección.",
"prefs.sketch.opening_closing": "Apertura y cierre",
"prefs.sketch.prefer_free_points": "Preferir puntos móviles donde se solapen",
"prefs.sketch.project_options": "Preguntar primero las opciones de Proyectar",
"prefs.sketch.recent_snap": "Ajustar a donde se cruzan dos guías",
"prefs.sketch.select_3d_curve_points": "Seleccionar puntos de curvas de boceto 3D",
"prefs.sketch.select_3d_curve_points_help": "Muestra y permite seleccionar puntos de las curvas de boceto 3D dibujadas como referencias de construcción.",
"prefs.sketch.select_other_hidden": "Seleccionar otro llega a la geometría oculta",
"prefs.sketch.shortcut_cycles_modes": "El atajo cambia de modo al pulsarlo otra vez",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivado: vuelve a iniciar la herramienta.",
"prefs.sketch.snap_to_visible_grid": "Ajustar a los cruces visibles de la cuadrícula",
"prefs.sketch.snapping_guides": "Ajuste y guías",
"prefs.sketch.state_icons": "Iconos de estado",
"prefs.sketch.tracking_guides": "Guías de centro y punto medio",
@@ -3034,7 +2943,6 @@
"prefs.sketch_selection": "Boceto y selección",
"prefs.sketch_selection_heading": "Boceto y selección",
"prefs.sketch_selection_note": "Cómo se ajustan los bocetos y cómo seleccionan los clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punto de un boceto se ajusta a los cruces de las líneas que dibuja la cuadrícula cuando el puntero apunta a uno, y si no, a la retícula más fina entre ellos. Desactivado: el punto se ajusta solo a la retícula más fina, como antes.",
"prefs.sketch_state_icons": "Iconos de boceto en el color de su estado",
"prefs.sketch_state_icons_note": "En Historial y el explorador, el icono de un boceto toma el color de su estado (bajo restricción, totalmente restringido o en conflicto) y su info muestra el estado. Desactivado: todos los bocetos tienen el mismo icono.",
"prefs.sketch_tab_always": "Mantener la pestaña Boceto visible",
@@ -3556,12 +3464,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersión subsuperficial por CPU necesita la reducción de ruido desactivada. Se conservan tus ajustes.",
"render.cpu_subsurface_help": "La dispersión redirige la luz dentro del vidrio cerrado. Las tasas positivas necesitan iluminación indirecta con la reducción de ruido desactivada. La tasa es de colisiones por mm; 0 no añade dispersión. Un sesgo de fase positivo favorece las direcciones hacia delante; uno negativo, las hacia atrás.",
"render.cpu_summary": "Trazado CPU · reflejos · HDR · luces direccionales y puntuales",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads": "Procesos de CPU",
"render.cpu_threads_auto": "Automático",
"render.cpu_threads_custom": "Personalizado",
"render.cpu_threads_help": "Automático usa los workers de CPU disponibles. Un worker se ejecuta en serie. Más workers pueden necesitar más memoria.",
"render.cpu_threads_help": "Automático usa los procesos de CPU disponibles. Un proceso se ejecuta en serie. Más procesos pueden necesitar más memoria.",
"render.cpu_threads_single": "Uno",
"render.cpu_threads_web": "El renderizado en el navegador usa un worker cooperativo. Se conserva la elección de workers guardada para el escritorio.",
"render.cpu_threads_web": "El renderizado en el navegador usa un proceso cooperativo. Se conserva la elección de procesos guardada para el escritorio.",
"render.cpu_transparent_help": "El PNG transparente quita el fondo y conserva la cobertura de los bordes. La opacidad del cuerpo y de la cara debe mantenerse al 100 %.",
"render.cpu_volume_enable_path": "Activar iluminación indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing en CPU · profundidad {depth} · {spp} muestras/píxel · semilla {seed} · sombras trazadas. Conserva la atmósfera definida y el presupuesto de imagen.",
@@ -3736,7 +3644,6 @@
"render.error_pbr_opacity": "PBR opaco requiere opacidad 1",
"render.error_physical_glass": "la receta seleccionada no es vidrio físico",
"render.error_preview_device": "la previsualización de producto requiere un dispositivo instalado con búfer de profundidad",
"render.error_product_placement": "colocación de producto no válida",
"render.error_product_triangle": "triángulo de producto no válido",
"render.error_provisional_identity": "identidad provisional de render o cuenta de muestras no válida",
"render.error_quality_bounds": "la proyección de calidad supera los límites nativos",
@@ -5061,7 +4968,6 @@
"ui.agent_chat_allow_once": "Permitir una vez",
"ui.agent_chat_allowed": "Permitido",
"ui.agent_chat_answered": "Respuesta: {option}",
"ui.agent_chat_answered_in": "Respondido en {time}",
"ui.agent_chat_approval": "¿Permitir que el agente haga esto?",
"ui.agent_chat_attach_selection": "Adjuntar selección",
"ui.agent_chat_attach_selection_tip": "Envía con el mensaje lo que esté seleccionado en el modelo (caras, aristas, operaciones, cuerpos) para que el agente sepa a qué te refieres",
@@ -5090,7 +4996,6 @@
"ui.agent_chat_ctx_bodies": "Cuerpos",
"ui.agent_chat_ctx_elements": "Elementos",
"ui.agent_chat_ctx_features": "Operaciones",
"ui.agent_chat_ctx_gone": "Ya no está en el diseño: se ha cambiado o eliminado.",
"ui.agent_chat_ctx_more": "+{n} más",
"ui.agent_chat_ctx_other": "Otros",
"ui.agent_chat_custom_heading": "Modelo personalizado",
@@ -5104,7 +5009,6 @@
"ui.agent_chat_dock_right": "Acoplar a la derecha",
"ui.agent_chat_drop_hint": "Suelta para adjuntar a tu próximo mensaje",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Finalizado tras {time}",
"ui.agent_chat_exited": "El agente se ha detenido.",
"ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un archivo CAD se importa al diseño, no se lee",
@@ -5142,7 +5046,6 @@
"ui.agent_chat_model_no_tools": "{model} no puede usar las herramientas de SolidCraft, así que solo puede chatear. Elige un modelo con soporte de herramientas.",
"ui.agent_chat_model_rejected": "El agente no aceptó {model}: {error}",
"ui.agent_chat_model_search": "Buscar modelos…",
"ui.agent_chat_model_search_or_id": "Busca o escribe cualquier ID de modelo…",
"ui.agent_chat_model_tip": "Modelo que utiliza el agente. Se recuerda tu elección para este agente.",
"ui.agent_chat_models_failed": "No se pudieron listar modelos: {error}",
"ui.agent_chat_models_heading": "Modelos disponibles",
@@ -5154,7 +5057,6 @@
"ui.agent_chat_next_picture": "Imagen siguiente",
"ui.agent_chat_no_cli": "No se ha encontrado solidcraft-cli junto a la aplicación, por lo que el agente no puede acceder al diseño.",
"ui.agent_chat_no_control": "No se ha iniciado el punto de conexión del chat del agente, por lo que no puede acceder al diseño.",
"ui.agent_chat_no_design_hint": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: no se ha encontrado solidcraft-cli junto a la aplicación. Instala SolidCraft con solidcraft-cli al lado (en Windows, el MSI o el zip portátil) o define SOLIDCRAFT_CLI con su ruta, y vuelve a conectar.",
"ui.agent_chat_no_images": "Este agente no admite imágenes",
"ui.agent_chat_no_picture": "No hay ninguna imagen en el portapapeles",
"ui.agent_chat_no_tool_call": "El modelo no usó las herramientas de SolidCraft, así que el diseño no cambió. Elige un modelo que soporte llamada a herramientas; véase Modelos locales en la guía del chat del agente.",
@@ -5169,7 +5071,7 @@
"ui.agent_chat_permission": "El agente solicita ejecutar: {title}",
"ui.agent_chat_pick": "Agente",
"ui.agent_chat_pick_context": "Elegir contexto",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo y pulsa OK: se añaden al mensaje como una ficha donde está el cursor (escribir @ hace lo mismo)",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo para que el agente los examine y pulsa Aceptar",
"ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…",
"ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)",
"ui.agent_chat_pop_out": "Abrir en su propia ventana, que puedes mover a otra pantalla",
@@ -5202,25 +5104,16 @@
"ui.agent_chat_side": "Lado del chat del agente",
"ui.agent_chat_signed_in": "sesión iniciada",
"ui.agent_chat_snap_none_found": "No se ha encontrado ningún agente de programación. El paquete Snap está confinado, así que no puede ver ni iniciar los agentes de programación instalados en este equipo. Para usarlos, instala SolidCraft desde el paquete AppImage, .deb o .rpm. Un agente que use una API compatible con OpenAI también funciona en el paquete Snap: configúralo con Añadir agente.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Se requiere iniciar sesión",
"ui.agent_chat_state_busy": "Trabajando…",
"ui.agent_chat_state_exited": "Agente detenido",
"ui.agent_chat_state_failed": "Conexión fallida",
"ui.agent_chat_state_no_design": "Sin acceso al diseño",
"ui.agent_chat_state_off": "Sin conexión",
"ui.agent_chat_state_ready": "Listo",
"ui.agent_chat_state_starting": "Iniciando…",
"ui.agent_chat_stop": "Detener",
"ui.agent_chat_stop_tip": "Detener el agente y todo lo que haya iniciado",
"ui.agent_chat_stop_turn_tip": "Detener el agente (Esc). Los mensajes en cola permanecen en cola.",
"ui.agent_chat_thinking_for": "Pensando durante {time}",
"ui.agent_chat_tool_running_for": "en ejecución durante {time}",
"ui.agent_chat_tools_not_started": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: se ha encontrado solidcraft-cli, pero sus herramientas no se han iniciado (el error del chat explica por qué). Vuelve a conectar; si sigue pasando, reinstala SolidCraft.",
"ui.agent_chat_transcript_assistant": "Asistente:",
"ui.agent_chat_transcript_user": "Usuario:",
"ui.agent_chat_turn_limit": "Cambios por mensaje",
"ui.agent_chat_turn_unlimited": "Sin límite",
"ui.agent_chat_undo_changes": "Deshacer estos cambios",
@@ -5409,7 +5302,6 @@
"ui.collapse": "Contraer",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visión del color",
"ui.colour_vision_bends": "Pliegues",
"ui.colour_vision_deutan": "Deuteranopía",
"ui.colour_vision_deutan_tip": "Para personas con sensibilidad reducida o ausente al verde",
"ui.colour_vision_failed": "Fallido",
@@ -5439,24 +5331,14 @@
"ui.conic_curve": "Curva cónica",
"ui.constant": "Constante",
"ui.constraints_conflict": "Conflicto entre restricciones",
"ui.construct_auto.chosen": "Elegido a partir de tus selecciones. Elige otra referencia, o quita una, para cambiar el método.",
"ui.construct_auto.no_fit": "Elige otras referencias: ningún método se puede construir con estas.",
"ui.construct_auto.pick": "Elige con qué construirlo: el método se deduce de tus selecciones.",
"ui.construct_auto.refs": "Referencias",
"ui.construct_auto.select": "Selecciona primero las referencias con las que construir",
"ui.construct_axes": "Ejes",
"ui.construct_planes": "Planos",
"ui.construct_points": "Puntos",
"ui.construction": "Construcción",
"ui.construction.angle_hint": "Línea de construcción: haz clic en un punto, luego escribe un ángulo o elige una dirección",
"ui.construction.axis_hint": "Línea de construcción: haz clic en un punto o escribe su distancia al eje del boceto",
"ui.construction.bisector_hint": "Bisectriz: elige dos líneas rectas que se intersecten, luego la solución deseada",
"ui.construction.common_hint": "Tangente común: elige dos círculos/arcos, luego la solución deseada",
"ui.construction.contact_hint": "Tangente: elige un círculo/arco o spline/cónica abierta, luego el punto de contacto (libre)",
"ui.construction.external_hint": "Tangente: elige un círculo o arco, un punto externo, luego la solución deseada",
"ui.construction.free_hint": "Línea de construcción: haz clic en dos puntos; clic derecho para reiniciar",
"ui.construction.normal_needs_length": "Una línea infinita es siempre de construcción: escribe una longitud para dibujar una línea normal",
"ui.construction.parallel_hint": "Línea de construcción: elige una línea recta y luego haz clic o escribe el desfase; haz clic con el botón derecho para cambiar la referencia",
"ui.construction.point_hint": "Línea de construcción: haz clic en el punto por el que debe pasar",
"ui.construction.reference_hint": "Línea de construcción: elige una línea recta, luego un punto; clic derecho para cambiar la",
"ui.construction.ribbon_label": "X-Líneas",
@@ -5630,10 +5512,7 @@
"ui.explode_needs_two_components": "Vista explosada separa los componentes, así el diseño necesita dos o más (Nuevo componente, Insertar diseño o Crear componentes desde cuerpos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
"ui.export_selects_only_body": "Seleccionar el único cuerpo al exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF y Enviar al laminador empiezan con el cuerpo del diseño seleccionado cuando solo hay uno.",
"ui.extend": "Extender",
"ui.extent": "Extensión",
"ui.extent_type": "Tipo de extensión",
@@ -5693,8 +5572,6 @@
"ui.finest": "Muy fina",
"ui.finish_sketch": "Finalizar boceto",
"ui.first_dimension_scales": "La primera cota escala el dibujo",
"ui.first_dimension_scales_about_origin": "Escalar respecto al origen del boceto",
"ui.first_dimension_scales_about_origin_tip": "Activado: el dibujo siempre crece o se encoge respecto al origen del boceto, así que una forma dibujada lejos del origen se mueve. Desactivado: un dibujo que nada ata al origen se escala respecto a su propio centro y se queda donde se dibujó.",
"ui.first_dimension_scales_tip": "Activado: la primera cota de longitud de un boceto escala el dibujo que mide, de modo que su forma no cambia. Desactivado: la cota solo dimensiona lo que mide.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fijar/liberar",
@@ -5950,7 +5827,7 @@
"ui.origin": "Origen",
"ui.palette_construction_mode": "Modo de construcción (X)",
"ui.palette_more": "Más",
"ui.palette_sketch_options": "Opciones de boceto",
"ui.palette_sketch_options": "Opciones de croquis",
"ui.pan": "Desplazar",
"ui.pan_zoom_orbit": "Desplazar, zoom y orbitar",
"ui.panel_fixture.apply": "Aplicar",
@@ -6107,7 +5984,7 @@
"ui.print_face_cleared": "{body}: cara de impresión limpiada",
"ui.print_face_input": "Cara de impresión",
"ui.print_face_pick": "Selecciona una cara plana de un cuerpo sólido para hacerla la cara de impresión",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar al laminador coloca el cuerpo sobre ella",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar a Laminador coloca el cuerpo sobre ella",
"ui.profile": "Perfil",
"ui.profile_plane": "Plano de perfil",
"ui.profiles": "Perfiles",
@@ -6151,14 +6028,11 @@
"ui.relink_design_changed": "El ensamblaje cambió mientras se elegía un origen. Inicia Revincular origen de nuevo.",
"ui.relink_done": "Origen revinculado en todas las instancias.",
"ui.relink_not_linked": "Este componente no está vinculado a un diseño de origen.",
"ui.relink_one_done": "Origen revinculado en esta instancia.",
"ui.relink_select_one": "Selecciona primero un componente vinculado o una de sus instancias.",
"ui.relink_source_all": "Revincular origen (todas las instancias)…",
"ui.relink_source_one": "Revincular origen…",
"ui.removeFromQuickAccess": "Quitar de Acceso rápido",
"ui.removeFromToolbar": "Quitar de la barra de herramientas",
"ui.removeSection": "Quitar sección",
"ui.renameSelectedBodies": "Cambiar nombre de los cuerpos seleccionados",
"ui.render": "Renderizar",
"ui.renderer": "Motor de renderizado",
"ui.repeat": "Repetir",
@@ -6250,16 +6124,6 @@
"ui.sketch_intersect_selection_filter": "Filtro de selección",
"ui.sketch_keys_only": "Mantener las teclas del modelo fuera de los bocetos",
"ui.sketch_keys_only_tip": "Mientras editas un boceto, solo se ejecutan las teclas de boceto y las que funcionan en todas partes; una tecla de modelo como E o F no hace nada, así que una tecla perdida no puede cerrar un boceto pesado.",
"ui.sketch_links_added_curve": "{command} ha añadido 1 curva al boceto.",
"ui.sketch_links_added_curves": "{command} ha añadido {count} curvas al boceto.",
"ui.sketch_links_added_curves_and_points": "{command} ha añadido {curves} y {points} al boceto.",
"ui.sketch_links_added_point": "{command} ha añadido 1 punto al boceto.",
"ui.sketch_links_added_points": "{command} ha añadido {count} puntos al boceto.",
"ui.sketch_links_curve_count": "{count} curvas",
"ui.sketch_links_curve_count_one": "1 curva",
"ui.sketch_links_hidden": "El resultado está oculto porque {option} está desactivado en la paleta del boceto.",
"ui.sketch_links_point_count": "{count} puntos",
"ui.sketch_links_point_count_one": "1 punto",
"ui.sketch_modifiers": "Modificadores del boceto",
"ui.sketch_project_geometry": "Geometría",
"ui.sketch_report.all_constrained": "Todo está totalmente restringido.",
@@ -6299,7 +6163,6 @@
"ui.step_values": "Incrementar valores con las flechas y la rueda",
"ui.step_values_tip": "En un campo de valor en el que estés escribiendo, Arriba/Abajo cambian un número simple de 1 en 1 (Mayús: 10, Alt: 0,1), y la rueda del ratón sobre el campo hace lo mismo. Las expresiones se dejan tal como se escriben.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Enderezar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
@@ -6373,8 +6236,6 @@
"ui.view_angles_yaw": "Rumbo",
"ui.view_angles_zero": "Poner {axis} a 0°",
"ui.view_centre": "Centro de la vista",
"ui.view_compass_tip": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar.",
"ui.view_compass_tip_snap": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar; mantén Mayús para ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} aristas",
-5
View File
@@ -19,14 +19,9 @@ solidcraft-kernel = { workspace = true }
solidcraft-profile = { workspace = true }
solidcraft-render = { workspace = true }
solidcraft-derive = { workspace = true }
solidcraft-format = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
log = { workspace = true }
sha2 = { workspace = true }
web-time = { workspace = true }
# Lists the format change files (build.rs).
[build-dependencies]
solidcraft-format = { workspace = true }
+72 -8
View File
@@ -1,12 +1,18 @@
//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in
//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are
//! generated (`solidcraft_format::build`) so that a change adds a file and never edits a line
//! another change also edits.
//! generated so that a change adds a file and never edits a line another change also edits.
use std::path::PathBuf;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use solidcraft_format::build::Family;
/// A family's change files: their folder, the last format before them, and the prefix of the
/// family's lists in `format.rs`.
struct Family {
folder: &'static str,
base: u32,
list_prefix: &'static str,
}
/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are
/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1.
@@ -15,10 +21,68 @@ const FAMILIES: [Family; 2] =
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES).unwrap_or_else(|e| panic!("{e}"));
for folder in &lists.watch {
println!("cargo:rerun-if-changed={}", folder.display());
let mut out = String::new();
let mut mods = String::new();
for family in &FAMILIES {
lists(family, &manifest, &mut out, &mut mods);
}
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, lists.code).unwrap();
std::fs::write(dest, out).unwrap();
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) {
let Family { folder, base, list_prefix } = family;
let root = manifest.join(folder);
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = base + 1 + i as u32;
assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("{module}v{version}"), out, mods);
out.push_str(" ]),\n");
}
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods);
out.push_str("];\n\n");
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
assert!(
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"{}: name a format change file with lowercase letters, digits and dashes",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use crate::format::{CheckNumbers as _, DRAWING};
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
+212 -74
View File
@@ -243,30 +243,6 @@ use serde_json::Value;
use crate::{DocError, Document, Result};
pub use solidcraft_format::{Alias, Newer};
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family, the shared `solidcraft_format`.
pub type Family<'a> = solidcraft_format::Family<'a, DocError>;
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
pub(crate) type Formats<'a> = solidcraft_format::Formats<'a, DocError>;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) type Part = solidcraft_format::Part<DocError>;
impl From<solidcraft_format::FormatError> for DocError {
fn from(e: solidcraft_format::FormatError) -> Self {
match e {
solidcraft_format::FormatError::Invalid(why) => DocError::Invalid(why),
}
}
}
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const FORMAT: u32 = FORMATS.current();
@@ -274,61 +250,223 @@ pub const FORMAT: u32 = FORMATS.current();
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// Another spelling a design of format 29 may carry: the personal format-29 renderer's name for
/// the engineering material cards. Its card schema is unchanged; recognising the alias must never
/// grant support to another reader, so it counts only while this build has the change.
const DESIGN_ALIASES: [Alias; 1] = [Alias { format: 29, name: "physical-material-cards", known_as: "material-engineering-cards" }];
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
noun: &'static str,
/// The tag before the number in a file's `format`.
prefix: &'static str,
/// The tag files saved before the rename carry: read, never written (docs/compatibility.md).
legacy_prefix: Option<&'static str>,
/// The format of a file that names none: designs saved before format strings are format 1; a
/// drawing always names its own.
untagged: Option<u32>,
formats: Formats<'a>,
}
/// Designs (`solidcraft/N`): the upgrade steps written out up to format 22, then the change files
/// in `src/format/`.
pub const DESIGN: Family<'static> = Family {
app: "SolidCraft",
noun: "design",
prefix: "solidcraft/",
legacy_prefix: Some(LEGACY_FORMAT_PREFIX),
untagged: Some(1),
aliases: &DESIGN_ALIASES,
formats: FORMATS,
};
pub const DESIGN: Family<'static> =
Family { noun: "design", prefix: "solidcraft/", legacy_prefix: Some(LEGACY_FORMAT_PREFIX), untagged: Some(1), formats: FORMATS };
/// Drawings (`solidcraft-drawing/N`, #3890): format 1 is the first drawing record, and every later
/// change is a file in `src/drawing/format/`.
pub const DRAWING: Family<'static> = Family {
app: "SolidCraft",
noun: "drawing",
prefix: "solidcraft-drawing/",
legacy_prefix: None,
untagged: None,
aliases: &[],
formats: Formats { hand: &[], folded: DRAWING_FOLDED, pending: DRAWING_PENDING },
};
/// Every number a file of a family holds is in range ([`check_numbers`]), but for its top-level
/// `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`), kept as it is
/// and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) trait CheckNumbers {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()>;
}
impl Family<'_> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
impl CheckNumbers for Family<'_> {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.formats.upgrade(self.noun, v, version, applied)
}
/// Every number a file of the family holds is in range ([`check_numbers`]), but for its
/// top-level `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`),
/// kept as it is and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun(), 1)),
v => check_numbers(v, self.noun(), 0),
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun, 1)),
v => check_numbers(v, self.noun, 0),
}
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
}
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
pending: &'a [Part],
}
impl Formats<'_> {
const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// A concept is implemented only when this reader registers its descriptor.
fn knows_part_name(&self, name: &str) -> bool {
self.folded.iter().flat_map(|(_, parts)| *parts).chain(self.pending).any(|part| part.name == name)
}
/// Read-only spelling from the personal format-29 renderer. Its card schema is
/// unchanged; recognizing the alias must never grant support to another reader.
fn canonical_applied_name<'name>(&self, version: u32, name: &'name str) -> &'name str {
if version == 29 && name == "physical-material-cards" && self.knows_part_name("material-engineering-cards") {
"material-engineering-cards"
} else {
name
}
}
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
let already_applied =
|part: &Part| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
if applied.is_some() {
for part in self.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match self.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in self.parts(from + 1).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || DocError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
DESIGN.format_string(n)
}
@@ -344,7 +482,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
/// Never add to it: a new format is a file in `src/format/pending/`.
type JsonStep = solidcraft_format::Step<DocError>;
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|_| Ok(()),
v2_to_v3,
@@ -1224,30 +1362,30 @@ mod tests {
#[test]
fn pending_changes_apply_in_order_after_older_formats() {
let pending = [traced("a-first"), traced("b-second")];
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade(&mut v, version, None).unwrap();
formats.upgrade("design", &mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
@@ -1259,16 +1397,16 @@ mod tests {
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
let next = HAND_WRITTEN + folded.len() as u32 + 1;
folded.push((next, &parts));
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] }, ..DESIGN };
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
formats.upgrade("design", &mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade(&mut released, next, None).unwrap();
formats.upgrade("design", &mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
@@ -1277,11 +1415,11 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let pending = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let folded = Family { formats: Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] }, ..DESIGN };
let pending = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let folded = Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] };
for formats in [pending, folded] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(trace_of(&file), ["b", "a"], "never replay existing card semantics");
}
}
@@ -1290,7 +1428,7 @@ mod tests {
fn personal_card_alias_without_support_is_a_newer_builds() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
@@ -1301,7 +1439,7 @@ mod tests {
let first = [traced("a-first")];
let second = [traced("b-second")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
@@ -1313,7 +1451,7 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
@@ -1325,14 +1463,14 @@ mod tests {
fn alias_and_canonical_markers_never_reapply_the_card_migration() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first"), traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
for names in [
vec!["physical-material-cards".into()],
vec!["physical-material-cards".into(), "material-engineering-cards".into()],
vec!["material-engineering-cards".into(), "physical-material-cards".into()],
] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut file, 29, Some(&names)).unwrap();
formats.upgrade("design", &mut file, 29, Some(&names)).unwrap();
assert_eq!(trace_of(&file), ["b", "a"]);
}
}
@@ -1342,10 +1480,10 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let mut file = serde_json::json!({"trace": ["a"], "metadata": {"format_pending": ["physical-material-cards"]}});
let metadata = file["metadata"].clone();
formats.upgrade(&mut file, 29, None).unwrap();
formats.upgrade("design", &mut file, 29, None).unwrap();
assert_eq!(trace_of(&file), ["a", "b"]);
assert_eq!(file["metadata"], metadata);
}
@@ -1357,7 +1495,7 @@ mod tests {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Family { formats: Formats { hand: &[], folded: &[(2, &parts)], pending }, ..DESIGN };
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
}
}
@@ -1368,14 +1506,14 @@ mod tests {
fn pending_concepts_moved_to_later_formats_are_not_reapplied() {
let first = [traced("a-first")];
let later = [traced("b-second")];
let pending = Family { formats: Formats { hand: &[], folded: &[(2, &first)], pending: &later }, ..DESIGN };
let folded = Family { formats: Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] }, ..DESIGN };
let pending = Formats { hand: &[], folded: &[(2, &first)], pending: &later };
let folded = Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] };
for formats in [pending, folded] {
let mut experimental = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut experimental, 2, Some(&["b-second".into()])).unwrap();
formats.upgrade("design", &mut experimental, 2, Some(&["b-second".into()])).unwrap();
assert_eq!(trace_of(&experimental), ["b", "a"]);
let mut released = serde_json::json!({"trace": ["a"]});
formats.upgrade(&mut released, 2, None).unwrap();
formats.upgrade("design", &mut released, 2, None).unwrap();
assert_eq!(trace_of(&released), ["a", "b"], "ordinary released files still get later migrations");
}
}
+326 -21
View File
@@ -11,9 +11,7 @@
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. What keeps them is the shared `solidcraft_format::newer`; what
//! is a design's (its features kept as placeholders, its parameters left out, the sections a design
//! can't open without) is here. Saving keeps the file's own format number and change list:
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
@@ -23,19 +21,16 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use solidcraft_format::newer::{leave_out_unreadable, looks_default, read_around, short};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
pub use solidcraft_format::newer::NewerFile;
/// The app, as a newer file's notice names it.
const APP: &str = "SolidCraft";
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 6] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob", "texture_blob"];
@@ -56,16 +51,132 @@ impl RawFeature {
}
}
/// What a design `newer` says a newer build saved holds that this build doesn't read: nothing
/// left out of it yet (a design container's reader adds what it leaves out).
pub fn newer_file(newer: &Newer) -> NewerFile {
NewerFile::new(APP, newer)
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file = newer_file(&newer);
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
@@ -84,7 +195,12 @@ pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, n
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
file.compare(&upgraded, &known, FOREIGN_TAG, &CONTAINER_ONLY);
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
@@ -126,27 +242,72 @@ fn salvage(
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let note = format!("parameter `{name}` ({})", short(&why.to_string(), "design"));
file.keep_element("params", param.clone(), note);
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
leave_out_unreadable(top, file, "design", piece);
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
read_around(top, file, &CORE, "design", |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied))
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why, "design");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
@@ -201,16 +362,160 @@ fn unwrapped(feature: &mut Value) {
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let Some(file) = &doc.newer_file else { return Vec::new() };
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
file.restore_into(value)
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
-32
View File
@@ -423,38 +423,6 @@ mod tests {
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn groove_on_bottom_rim_of_rounded_shell() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": solidcore_record::Plane::XY, "name": "Outline"}));
run(
&mut s,
"sketch.rectangle.rounded",
json!({"p0": [-25.263865881072945, -28.541660374585607], "p1": [25.263865881072945, 28.541660374585607], "radius": 3}),
);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 0]], "thickness": 2}));
let state = s.model.state();
let shell = &state.body("Body1").unwrap().body;
assert!(shell.validity().is_empty(), "shell: {:?}", shell.validity());
let (plane, band) = solidcraft_kernel::rim_band(shell, Vec3::new(0.0, -27.541660374585607, 0.0), 1.5, 0.0, false).unwrap();
let cutter = solidcraft_kernel::extrude(&plane, &[band], -2.0, 0.0).unwrap();
for body in &cutter {
assert!(body.validity().is_empty(), "cutter: {:?}", body.validity());
}
let before = volume(&mut s);
let result = s.execute("plastic.lip", &json!({"face": [0, -27.541660374585607, 0], "width": 1.5, "height": 2, "type": "groove"}));
assert!(result.is_ok(), "{result:?}");
let area = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
let w = 50.52773176214589;
let h = 57.083320749171214;
let groove_area = area(w - 1.0, h - 1.0, 2.5) - area(w - 4.0, h - 4.0, 1.0);
let removed = before - volume(&mut s);
assert!((removed - groove_area * 2.0).abs() < 0.05, "removed {removed}, expected {}", groove_area * 2.0);
assert!(s.model.state().body("Body1").unwrap().body.validity().is_empty());
}
/// A lip follows its rim face when a parameter moves it (#11209): it was kept by a point
/// only, so a taller box left the point below the rim and the lip failed.
#[test]
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "solidcraft-format"
description = "Versioned JSON file formats: the format tag, the registry of versions (hand-written steps, then one folder of changes per version, then the pending changes), the upgrade of an older file and the telling of a newer one"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
# The container: content-addressed blobs (SHA-256) in a ZIP package.
sha2 = { workspace = true }
zip = { workspace = true }
-66
View File
@@ -1,66 +0,0 @@
# solidcraft-format
Versioned file formats: what an app needs to read every file it ever wrote, to open a file a
newer build of it wrote without losing what it can't read, and to package a file with its binary
payloads. It holds no app types; each app registers its own families of files and their versions,
and keeps its own payload encodings.
A file starts with `"format": "<prefix>N"`. A family's versions are, in order: the steps written
out by hand for its first formats, then one folder per format (`v<N>/`, one file per change, each
with a JSON step when older files are rewritten as they open), then the changes in flight
(`pending/`), which make the next number until a release folds them into `v<N>/`. App code only
knows the current format: all translation lives in the steps.
## API
| Item | What it does |
|---|---|
| `Family { app, noun, prefix, legacy_prefix, untagged, aliases, formats }` | One kind of file: its tag, its messages, its versions |
| `Formats { hand, folded, pending }` | A family's versions: hand-written steps, folded formats and their changes, pending changes |
| `Part { name, step }` | One change: its file's name, and its JSON step if it has one |
| `Step<E>` | `fn(&mut Value) -> Result<(), E>`: a JSON-level step, returning the app's error |
| `Alias { format, name, known_as }` | Another spelling of a change name that files of one format may carry |
| `Family::current()` | The format this build writes |
| `Family::format_string(n)`, `Family::version_of(tag)` | A format's tag, a tag's format (the legacy prefix read too) |
| `Family::pending_names()` | The changes in flight, written into a file's `format_pending` |
| `Family::take_version(&mut json)` | A file's format and its `format_pending`, taken out before anything else is read; a file not of the family is refused |
| `Family::newer(version, applied)` | `Some(Newer)` when a newer build wrote the file: a number past this build's, or a change it doesn't know |
| `Family::upgrade(&mut json, version, applied)` | Run the steps that bring the file to this build's format, each once |
| `Newer { format, reads_up_to, unknown_changes }` | How a file shows a newer build wrote it |
| `FormatError` | This crate's refusals; the app's error takes it with `From` |
| `build::lists(manifest, families)` | For a build script: the generated `FOLDED` and `PENDING` lists and a module per change file |
| `newer::NewerFile` | What a file a newer build saved holds that this build doesn't read: `new(app, &newer)`, `notice`, `warnings`, `report`, `keep_section`, `keep_element`, `compare` (the file against what was read of it), `restore_into` (put it all back on saving), `mentions`, `needs_container`, and the container's unknown `parts` and `container` |
| `newer::leave_out_unreadable`, `newer::read_around` | Leave out, and keep, the top-level sections a file can't be read with |
| `newer::short`, `newer::looks_default` | A reader's error's first line; a value a writer leaves out |
| `container::Writer` | A ZIP container being written: `part`, `blob` (content-addressed, `blobs/<sha256>.<ext>`), `room`/`space` for optional caches, `finish` |
| `container::Reader` | One being read over a seekable source, its directory checked on opening: `document`, `limited_part`, `blob`, `limited_blob`, `has_part`, `part_names`, `hydrated` |
| `container::Limits` | The app's budgets: entries, expanded size, per-part limits, Deflate level, file size |
| `container::Compression`, `container::Thumbnail`, `container::digest`, `container::is_container` | Fast or compact payloads; a PNG picture keyed to its file; the SHA-256 name of bytes; the ZIP signature |
## Registering a family
The app's build script lists the folders:
```rust
use solidcraft_format::build::Family;
// format 1 is the first record, so its folded formats start at 2 (`base` is the last format before them)
const FAMILIES: [Family; 1] = [Family { folder: "src/format", base: 1, list_prefix: "" }];
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES)?;
// write lists.code to OUT_DIR, rerun on each of lists.watch
```
and its format module includes them, with its error type:
```rust
type Part = solidcraft_format::Part<AppError>;
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
pub const DESIGN: solidcraft_format::Family<'static, AppError> = solidcraft_format::Family {
app: "App", noun: "design", prefix: "app/", legacy_prefix: None, untagged: None, aliases: &[],
formats: solidcraft_format::Formats { hand: &[], folded: FOLDED, pending: PENDING },
};
```
A change file is `v<N>/<name>.rs` or `pending/<name>.rs`, named with lowercase letters, digits and
dashes, starting with a `//!` note (what is saved, what an older file gets, what the last release
does with a file that has it), and with `pub fn step(v: &mut Value) -> Result<(), AppError>` when
older files are rewritten as they open.
-100
View File
@@ -1,100 +0,0 @@
//! For a build script: the generated lists of a family's changes. Each change is one `.rs` file in
//! the family's folder, in `v<N>/` once a release fixed its number, else in `pending/`; the lists
//! are generated so that a change adds a file and never edits a line another change also edits.
//!
//! The generated code, for the app to `include!`, is per family a `FOLDED` list (each folded
//! format and its parts) and a `PENDING` list, both prefixed by the family's `list_prefix`, then a
//! module per change file. It names the app's `Part` type, which the including module has in scope
//! (`type Part = solidcraft_format::Part<AppError>;`), and a change file's step as `step` (a
//! `pub fn step(v: &mut Value) -> Result<(), AppError>`, when the file has one).
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// A family's change files: their folder (relative to the crate's manifest), the last format
/// before them, and the prefix of the family's lists.
pub struct Family {
pub folder: &'static str,
pub base: u32,
pub list_prefix: &'static str,
}
/// What [`lists`] made: the code to write, and the folders the build script reruns on.
pub struct Lists {
pub code: String,
pub watch: Vec<PathBuf>,
}
/// The `FOLDED` and `PENDING` lists of every family, and a module for each change file. Refuses
/// versions that are not contiguous from a family's base, a change file not named with lowercase
/// letters, digits and dashes, and one that doesn't start with a `//!` note.
pub fn lists(manifest: &Path, families: &[Family]) -> Result<Lists, String> {
let mut code = String::new();
let mut mods = String::new();
let mut watch = Vec::new();
for family in families {
let root = manifest.join(family.folder);
family_lists(family, &root, &mut code, &mut mods)?;
watch.push(root);
}
code.push_str(&mods);
Ok(Lists { code, watch })
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn family_lists(family: &Family, root: &Path, out: &mut String, mods: &mut String) -> Result<(), String> {
let Family { folder, base, list_prefix } = family;
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(root)
.map_err(|e| format!("{}: {e}", root.display()))?
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = base + 1 + i as u32;
if *version != expected {
return Err(format!("{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)"));
}
}
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("{module}v{version}"), out, mods)?;
out.push_str(" ]),\n");
}
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods)?;
out.push_str("];\n\n");
Ok(())
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) -> Result<(), String> {
let Ok(entries) = std::fs::read_dir(dir) else { return Ok(()) };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
return Err(format!("{}: name a format change file with lowercase letters, digits and dashes", path.display()));
}
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if !text.trim_start().starts_with("//!") {
return Err(format!("{}: start a format change with a `//!` note", path.display()));
}
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
Ok(())
}
-339
View File
@@ -1,339 +0,0 @@
//! The file container: a ZIP package of named parts (the file's JSON, binary payloads, optional
//! caches), never extracted to filesystem paths. Payloads are content-addressed blobs,
//! `blobs/<sha256>.<extension>`, stored once however often they are referred to. Every size is
//! bounded by the app's [`Limits`]: the file, each part, all parts expanded, the payloads resolved,
//! and the number of entries; a part is read no further than its declared size (a decompression
//! bomb stops there). What a part holds, and how a payload is encoded, is the app's.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::FormatError;
/// How hard the payloads are compressed: Fast favours interactive file operations; Compact trades
/// CPU time for space.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compression {
#[default]
Fast,
Compact,
}
/// An app's budgets for its containers. The functions are read each time they are needed, so a
/// limit the app lets the user raise takes effect at once.
#[derive(Clone, Copy)]
pub struct Limits {
/// The most entries a container holds.
pub max_entries: usize,
/// The most bytes all parts may expand to, and all payloads may resolve to.
pub max_expanded: fn() -> usize,
/// The most bytes a part of this name may expand to.
pub part_limit: fn(&str) -> usize,
/// The Deflate level of a compressed part of this name and size.
pub level: fn(&str, usize) -> i64,
/// Whether a container of this many bytes may be read, or why not.
pub check_size: fn(u64) -> Result<(), String>,
}
fn invalid(message: impl std::fmt::Display) -> FormatError {
FormatError::Invalid(message.to_string())
}
/// Whether the bytes start as a container does, whatever the file is called.
pub fn is_container(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04")
}
/// The SHA-256 of the bytes, in lowercase hexadecimal: a blob's name, and how a cache is keyed to
/// the file it was written for.
pub fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// A container being written.
pub struct Writer {
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
names: BTreeSet<String>,
expanded: usize,
resolved: usize,
limits: Limits,
}
impl Writer {
pub fn new(limits: Limits) -> Writer {
Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0, limits }
}
/// The container's bytes.
pub fn finish(self) -> Result<Vec<u8>, FormatError> {
Ok(self.zip.finish().map_err(invalid)?.into_inner())
}
/// Add a part, compressed or stored. A name already written is written once.
pub fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<(), FormatError> {
if bytes.len() > (self.limits.part_limit)(name) {
return Err(invalid("part too large"));
}
if !self.names.insert(name.to_owned()) {
return Ok(());
}
self.expanded = self.expanded.checked_add(bytes.len()).ok_or_else(|| invalid("expanded size overflow"))?;
if self.expanded > (self.limits.max_expanded)() || self.names.len() > self.limits.max_entries {
return Err(invalid("package too large"));
}
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
let level = (self.limits.level)(name, bytes.len());
// A part of 4 GiB or more (only with a raised file limit) needs a ZIP64 entry.
let options = zip::write::SimpleFileOptions::default()
.compression_method(method)
.compression_level(compressed.then_some(level))
.large_file(bytes.len() as u64 >= u64::from(u32::MAX));
self.zip.start_file(name, options).map_err(invalid)?;
self.zip.write_all(bytes).map_err(invalid)
}
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep the
/// file within `budget` and its expanded size within the reader's budget?
pub fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
self.names.len().saturating_add(entries) <= self.limits.max_entries && extra <= self.space(entries, budget)
}
/// The bytes `entries` more optional parts may hold together ([`Writer::room`]).
pub fn space(&self, entries: usize, budget: usize) -> usize {
if self.names.len().saturating_add(entries) > self.limits.max_entries {
return 0;
}
// The bytes written so far, plus what the open compressed part may still hold back.
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
budget.saturating_sub(written.saturating_add(directory)).min((self.limits.max_expanded)().saturating_sub(self.expanded))
}
/// Add a payload as a blob, `blobs/<sha256>.<extension>`, and say its name, for the JSON that
/// refers to it.
pub fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value, FormatError> {
self.resolved = self
.resolved
.checked_add(bytes.len())
.filter(|&n| n <= (self.limits.max_expanded)())
.ok_or_else(|| invalid("resolved payloads too large"))?;
let name = format!("blobs/{}.{extension}", digest(bytes));
self.part(&name, bytes, compressed)?;
Ok(Value::String(name))
}
}
/// A container being read, over a seekable source. Opening checks the directory (the entry count,
/// unique and plain names, every part's declared size), not the parts.
pub struct Reader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
/// The bytes of payloads resolved so far, repeats counted: bounded by the expanded budget.
pub hydrated: usize,
document: Option<Arc<[u8]>>,
limits: Limits,
}
impl<R: Read + Seek> Reader<R> {
pub fn new(mut source: R, limits: Limits) -> Result<Self, FormatError> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
(limits.check_size)(length).map_err(invalid)?;
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. The bounded format
// does not need ZIP64/multiple disks: verify the declared entry count as well so
// duplicate names cannot silently select a payload.
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16, FormatError> {
let array: [u8; 2] = end.get(offset..offset + 2).ok_or_else(|| invalid("truncated ZIP directory"))?.try_into().map_err(invalid)?;
Ok(u16::from_le_bytes(array))
};
let declared = u16_at(10)? as usize;
if declared > limits.max_entries
|| u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != declared
|| end.len() != 22 + u16_at(20)? as usize
{
return Err(invalid("too many entries or unsupported ZIP directory"));
}
// A container is a ZIP with nothing in front of it. Saying so skips zip's search for a
// prefix, which reads back through the stored parts and would load a large payload
// before a preview can be shown.
let config = zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) };
let mut zip = zip::ZipArchive::with_config(config, source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
|| u16_at(10)? as usize != zip.len()
|| end.len() != 22 + u16_at(20)? as usize
{
return Err(invalid("duplicate entries or unsupported ZIP directory"));
}
let mut names = BTreeSet::new();
let mut total = 0u64;
for i in 0..zip.len() {
let part = zip.by_index_raw(i).map_err(invalid)?;
let name = part.name().map_err(invalid)?.into_owned();
if name.is_empty()
|| name.contains('\\')
|| name.split('/').any(|c| c.is_empty() || c == "." || c == "..")
|| !names.insert(name.to_owned())
{
return Err(invalid("duplicate or invalid archive entry"));
}
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
let limit = (limits.part_limit)(&name);
if part.size() > limit as u64 || total > (limits.max_expanded)() as u64 {
return Err(invalid("expanded package too large"));
}
}
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None, limits })
}
/// The file's own part, `document.json`, read once and kept: provisional caches and the
/// authoritative reading are bound to the same bytes, even if another process overwrites the
/// open file in place. Blobs remain hash-checked.
pub fn document(&mut self, limit: usize) -> Result<Arc<[u8]>, FormatError> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
/// Whether the container has a part of this name.
pub fn has_part(&self, name: &str) -> bool {
self.zip.index_for_name(name).is_some()
}
/// The names of every part, in the container's order.
pub fn part_names(&self) -> Vec<String> {
self.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect()
}
/// The part of this name, at most `limit` bytes.
pub fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>, FormatError> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
let expected = part.size();
if expected > limit as u64 {
return Err(invalid("part too large"));
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(expected as usize).map_err(|_| invalid("could not reserve required part"))?;
// A compressed part may expand beyond its declared size (a decompression bomb): stop
// one byte past it instead of at the limit.
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
Ok(bytes)
}
/// The blob `value`'s `field` names, of this extension, within its part limit.
pub fn blob(&mut self, value: &Value, field: &str, extension: &str) -> Result<&[u8], FormatError> {
let limit = (self.limits.part_limit)(&format!("blob.{extension}"));
self.limited_blob(value, field, extension, limit)
}
/// [`Reader::blob`] within `limit` bytes. Its hash is checked when it is first read, and every
/// reference counts against the resolved budget.
pub fn limited_blob(&mut self, value: &Value, field: &str, extension: &str, limit: usize) -> Result<&[u8], FormatError> {
let name = value.get(field).and_then(Value::as_str).ok_or_else(|| invalid(format!("missing `{field}` reference")))?;
let hash = name
.strip_prefix("blobs/")
.and_then(|n| n.strip_suffix(&format!(".{extension}")))
.filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
.ok_or_else(|| invalid("invalid blob reference"))?;
if !self.blobs.contains_key(name) {
let bytes = self.limited_part(name, limit)?;
if digest(&bytes) != hash {
return Err(invalid(format!("blob hash mismatch: {name}")));
}
self.blobs.insert(name.to_owned(), bytes);
}
let bytes = self.blobs.get(name).ok_or_else(|| invalid("missing blob"))?;
if bytes.len() > limit {
return Err(invalid("blob exceeds its resource budget"));
}
// Count repeated references too: a tiny archive must not expand into unbounded owned
// strings or arrays when the same blob is referenced repeatedly.
self.hydrated = self
.hydrated
.checked_add(bytes.len())
.filter(|&n| n <= (self.limits.max_expanded)())
.ok_or_else(|| invalid("resolved payloads too large"))?;
Ok(bytes)
}
}
/// A small PNG picture of the file, kept in the container as a disposable cache keyed to the
/// exact file it shows: `cache/thumbnail.json` (its key) and `cache/thumbnail.png`. Reading it
/// never resolves a payload; a picture that isn't one, or is stale, is a miss.
#[derive(Clone, Copy, Debug)]
pub struct Thumbnail {
/// The key's format tag.
pub format: &'static str,
/// The build that wrote it: only the same build reads it back.
pub app: &'static str,
/// Its width and height in pixels.
pub size: [usize; 2],
/// The most bytes it may have.
pub max_bytes: usize,
}
impl Thumbnail {
/// Whether the bytes start as a PNG of this size within the byte budget (not a decode: a
/// consumer decodes with its own dimension and allocation limits).
pub fn valid(&self, bytes: &[u8]) -> bool {
bytes.len() <= self.max_bytes
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((self.size[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((self.size[1] as u32).to_be_bytes().as_slice())
}
/// Add the picture, keyed to `document` (the file's own part), when it is one and there is
/// room for it within `budget`; otherwise leave it out, never failing the save.
pub fn write(&self, writer: &mut Writer, png: &[u8], document: &[u8], budget: usize) -> Result<(), FormatError> {
if !self.valid(png) {
return Ok(());
}
let key =
serde_json::to_vec(&serde_json::json!({"format": self.format, "app": self.app, "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if !writer.room(2, key.len().saturating_add(png.len()), budget) {
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
/// The picture, when this build wrote it for exactly the file's own part (`document.json`,
/// read within `document_limit`).
pub fn read<R: Read + Seek>(&self, reader: &mut Reader<R>, document_limit: usize) -> Result<Vec<u8>, FormatError> {
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(document_limit)?;
if key.get("format").and_then(Value::as_str) != Some(self.format)
|| key.get("app").and_then(Value::as_str) != Some(self.app)
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", self.max_bytes)?;
if !self.valid(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
Ok(png)
}
}
#[cfg(test)]
#[path = "container_tests.rs"]
mod tests;
-66
View File
@@ -1,66 +0,0 @@
use super::*;
const LIMITS: Limits = Limits {
max_entries: 8,
max_expanded: || 1 << 20,
part_limit: |name| if name.ends_with(".big") { 1 << 20 } else { 1024 },
level: |_, _| 1,
check_size: |length| if length <= 1 << 20 { Ok(()) } else { Err("too large".into()) },
};
/// Parts and blobs written are read back as they were; a blob is stored once, named by its hash,
/// and checked against it.
#[test]
fn parts_and_blobs_read_back() {
let mut writer = Writer::new(LIMITS);
writer.part("document.json", br#"{"a":1}"#, true).unwrap();
let first = writer.blob(b"payload", "bin", false).unwrap();
let again = writer.blob(b"payload", "bin", true).unwrap();
assert_eq!(first, again, "the same payload is one blob");
assert_eq!(first, Value::String(format!("blobs/{}.bin", digest(b"payload"))));
let bytes = writer.finish().unwrap();
assert!(is_container(&bytes));
let mut reader = Reader::new(Cursor::new(bytes), LIMITS).unwrap();
assert_eq!(reader.part_names().len(), 2);
assert!(reader.has_part("document.json") && !reader.has_part("other.json"));
assert_eq!(&*reader.document(64).unwrap(), br#"{"a":1}"#);
let reference = serde_json::json!({"blob": first});
assert_eq!(reader.blob(&reference, "blob", "bin").unwrap(), b"payload");
assert!(reader.blob(&reference, "blob", "png").is_err(), "another extension");
assert_eq!(reader.hydrated, 7);
reader.hydrated = (LIMITS.max_expanded)();
assert!(reader.blob(&reference, "blob", "bin").unwrap_err().to_string().contains("resolved payloads too large"));
}
/// A part past its limit, or more entries than the limit, are refused as they are written.
#[test]
fn budgets_are_kept_when_writing() {
let mut writer = Writer::new(LIMITS);
assert_eq!(writer.part("small.json", &[0; 1025], false), Err(FormatError::Invalid("part too large".into())));
assert!(writer.part("large.big", &[0; 1025], true).is_ok());
for i in 0..7 {
writer.part(&format!("{i}.json"), b"x", false).unwrap();
}
assert_eq!(writer.part("one-more.json", b"x", false), Err(FormatError::Invalid("package too large".into())));
assert!(!writer.room(1, 0, 1 << 20), "no room for an entry past the limit");
}
/// A thumbnail is kept only for the file it shows.
#[test]
fn a_thumbnail_is_keyed_to_its_file() {
let picture = Thumbnail { format: "app-thumbnail/1", app: "1.0", size: [2, 1], max_bytes: 1024 };
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend_from_slice(&2u32.to_be_bytes());
png.extend_from_slice(&1u32.to_be_bytes());
assert!(picture.valid(&png));
let mut writer = Writer::new(LIMITS);
writer.part("document.json", b"{}", false).unwrap();
picture.write(&mut writer, &png, b"{}", 8 << 20).unwrap();
let mut reader = Reader::new(Cursor::new(writer.finish().unwrap()), LIMITS).unwrap();
assert_eq!(picture.read(&mut reader, 64).unwrap(), png);
let mut writer = Writer::new(LIMITS);
writer.part("document.json", b"{\"changed\":1}", false).unwrap();
picture.write(&mut writer, &png, b"{}", 8 << 20).unwrap();
let mut reader = Reader::new(Cursor::new(writer.finish().unwrap()), LIMITS).unwrap();
assert!(picture.read(&mut reader, 64).is_err(), "stale");
}
-249
View File
@@ -1,249 +0,0 @@
//! Versioned file formats: what an app needs to read every file it ever wrote, and to open a file
//! a newer build of it wrote.
//!
//! A file starts with `"format": "<prefix>N"`. A [`Family`] is one kind of file (an app's designs,
//! its drawings): its tag, its own numbers, and how a file of an older number is brought up to
//! this build's, step by step (N → N+1 → … → current). The app's code only knows the current
//! format; all translation lives in the steps:
//! - the steps written out by hand for the first formats, one per format ([`Formats::hand`]);
//! - then one folder per format, `v<N>/`, one file per change in it ([`Part`]), each with a step
//! when files from before the change are rewritten as they open ([`Formats::folded`]);
//! - then the changes no release has a number for yet, in `pending/` ([`Formats::pending`]):
//! while there are any, the build writes the next number, with their names in the file's
//! `format_pending`, so a later build runs just the steps a file is missing. A release moves
//! them into the next `v<N>/`. [`build`] lists the folders for a build script.
//!
//! A file a newer build wrote says so by a number past this build's or a change name this build
//! doesn't have ([`Family::newer`]); the app then reads what it can and keeps the rest
//! ([`newer`]). A file is saved as JSON, or in a [`container`] with its binary payloads.
//!
//! Steps are JSON-level (the file's shape) and return the app's own error type; every error this
//! crate makes is a [`FormatError`], which the app's error type takes `From`.
use serde_json::Value;
pub mod build;
pub mod container;
pub mod newer;
/// Why a file is refused: its text says what is wrong, as the user reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FormatError {
/// The file isn't one of the family's, or it is damaged.
Invalid(String),
}
impl std::fmt::Display for FormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FormatError::Invalid(why) => f.write_str(why),
}
}
}
impl std::error::Error for FormatError {}
/// One JSON-level upgrade step: rewrites a file in place, or says why it can't.
pub type Step<E> = fn(&mut Value) -> Result<(), E>;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub struct Part<E> {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<Step<E>>,
}
/// Another spelling of a change name that a file of one format may carry (a build that named the
/// change otherwise): read as the change it stands for, never written.
#[derive(Clone, Copy, Debug)]
pub struct Alias {
/// The format whose files may say it.
pub format: u32,
/// The spelling in the file.
pub name: &'static str,
/// The change it stands for.
pub known_as: &'static str,
}
/// A family's upgrade path: the steps written out by hand, the folded formats after them (each a
/// list of changes) and the pending changes, which make the next format.
pub struct Formats<'a, E> {
/// Entry `i` turns format `i + 1` into `i + 2`.
pub hand: &'a [Step<E>],
/// Each format after the hand-written ones, oldest first, and its changes.
pub folded: &'a [(u32, &'a [Part<E>])],
/// The changes no release has a number for yet.
pub pending: &'a [Part<E>],
}
/// A kind of file whose format is versioned: its own format tag, its own numbers and its own
/// changes. Reading a file's format, telling a file a newer build saved and the upgrade are the
/// same for every family.
pub struct Family<'a, E> {
/// The app, as messages name it.
pub app: &'static str,
/// What a file of the family is called in messages.
pub noun: &'static str,
/// The tag before the number in a file's `format`.
pub prefix: &'static str,
/// The tag files saved under an older name carry: read, never written.
pub legacy_prefix: Option<&'static str>,
/// The format of a file that names none, if such files exist.
pub untagged: Option<u32>,
/// Other spellings of change names that files may carry.
pub aliases: &'a [Alias],
/// The upgrade path.
pub formats: Formats<'a, E>,
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
impl<E> Formats<'_, E> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part<E>]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// Whether a change of this name is anywhere in this build's history.
fn knows_part_name(&self, name: &str) -> bool {
self.folded.iter().flat_map(|(_, parts)| *parts).chain(self.pending).any(|part| part.name == name)
}
}
impl<E> Family<'_, E> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
/// What a file of the family is called in messages.
pub const fn noun(&self) -> &'static str {
self.noun
}
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// The change a name a file of format `version` lists stands for: its alias's, or itself.
/// An alias counts only while this build has the change it stands for, so recognising it
/// never grants support to another reader.
fn canonical_applied_name<'name>(&self, version: u32, name: &'name str) -> &'name str {
match self.aliases.iter().find(|a| a.format == version && a.name == name) {
Some(alias) if self.formats.knows_part_name(alias.known_as) => alias.known_as,
_ => name,
}
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history
/// (a branch's pending change may have landed at a later number). A list beside a format that
/// isn't made of changes means nothing (a tool or a test set an older number on a newer file).
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
let listed = applied.filter(|_| version > current || self.formats.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.formats.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
}
impl<E: From<FormatError>> Family<'_, E> {
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>), E> {
let (app, noun) = (self.app, self.noun);
let not_ours = |what: String| E::from(FormatError::Invalid(format!("not a {app} {noun} ({what})")));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun).map_err(E::from)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]): the changes of its format it already has, when it
/// was written while they were pending. The others' steps run first, and already-applied
/// changes never run again at their later number. A file past this build's number has no
/// steps to run.
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<(), E> {
let formats = &self.formats;
if version == 0 {
return Err(E::from(FormatError::Invalid(format!("not a {} {} (format 0)", self.app, self.noun))));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| formats.parts(version).is_some());
let already_applied =
|part: &Part<E>| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
if applied.is_some() {
for part in formats.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match formats.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in formats.parts(from + 1).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>, FormatError> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || FormatError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_, _>>().map(Some)
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;
-134
View File
@@ -1,134 +0,0 @@
use super::*;
use serde_json::json;
/// The app's error, as an app has one.
#[derive(Debug, PartialEq)]
struct AppError(String);
impl From<FormatError> for AppError {
fn from(e: FormatError) -> Self {
AppError(e.to_string())
}
}
/// Each step appends its name to the file's `log`, so the order steps ran in can be read back.
fn log(v: &mut Value, step: &str) {
if let Some(list) = v.get_mut("log").and_then(Value::as_array_mut) {
list.push(json!(step));
}
}
const HAND: [Step<AppError>; 2] = [
|v| {
log(v, "1to2");
Ok(())
},
|v| {
log(v, "2to3");
Ok(())
},
];
const V4: [Part<AppError>; 2] = [
Part {
name: "a",
step: Some(|v| {
log(v, "a");
Ok(())
}),
},
Part { name: "b", step: None },
];
const V5: [Part<AppError>; 1] = [Part {
name: "c",
step: Some(|v| {
log(v, "c");
Ok(())
}),
}];
const FOLDED: [(u32, &[Part<AppError>]); 2] = [(4, &V4), (5, &V5)];
const PENDING: [Part<AppError>; 2] = [
Part {
name: "d",
step: Some(|v| {
log(v, "d");
Ok(())
}),
},
Part {
name: "e",
step: Some(|v| {
log(v, "e");
Ok(())
}),
},
];
const ALIASES: [Alias; 1] = [Alias { format: 5, name: "old-c", known_as: "c" }];
/// Format 1 untagged; hand steps to 3; folded 4 and 5; pending changes make 6.
const FAMILY: Family<'static, AppError> = Family {
app: "App",
noun: "design",
prefix: "app/",
legacy_prefix: Some("old/"),
untagged: Some(1),
aliases: &ALIASES,
formats: Formats { hand: &HAND, folded: &FOLDED, pending: &PENDING },
};
fn steps_run(version: u32, applied: Option<&[String]>) -> Vec<String> {
let mut v = json!({"log": []});
FAMILY.upgrade(&mut v, version, applied).unwrap();
v["log"].as_array().unwrap().iter().map(|s| s.as_str().unwrap().to_string()).collect()
}
#[test]
fn tags_and_numbers() {
assert_eq!(FAMILY.current(), 6);
assert_eq!(FAMILY.format_string(6), "app/6");
assert_eq!(FAMILY.version_of("app/4"), Some(4));
assert_eq!(FAMILY.version_of("old/3"), Some(3), "the legacy tag is read");
assert_eq!(FAMILY.version_of("other/3"), None);
assert_eq!(FAMILY.pending_names(), vec!["d".to_string(), "e".to_string()]);
}
#[test]
fn a_file_is_taken_apart_or_refused_with_the_apps_words() {
let mut v = json!({"format": "app/6", "format_pending": ["d"], "x": 1});
assert_eq!(FAMILY.take_version(&mut v), Ok((6, Some(vec!["d".to_string()]))));
assert!(v.get("format_pending").is_none(), "taken out");
assert_eq!(FAMILY.take_version(&mut json!({})), Ok((1, None)), "untagged");
let refused = |mut v: Value| FAMILY.take_version(&mut v).unwrap_err().0;
assert_eq!(refused(json!([])), "not a App design (expected a JSON object)");
assert_eq!(refused(json!({"format": "other/2"})), "not a App design (format `other/2`)");
assert_eq!(refused(json!({"format": 2})), "not a App design (format is not a string)");
assert_eq!(refused(json!({"format": "app/0"})), "not a App design (format 0)");
assert_eq!(refused(json!({"format": "app/2", "format_pending": [1]})), "damaged design: `format_pending` is not a short list of names");
}
#[test]
fn every_step_runs_once_in_order() {
assert_eq!(steps_run(1, None), ["1to2", "2to3", "a", "c", "d", "e"]);
assert_eq!(steps_run(4, None), ["c", "d", "e"]);
assert_eq!(steps_run(6, None), Vec::<String>::new());
// A file written while `d` was pending has it: only `e` runs.
assert_eq!(steps_run(6, Some(&["d".to_string()])), ["e"]);
// A file of format 5 written while `c` was still pending, without it: `c` runs first.
assert_eq!(steps_run(5, Some(&[])), ["c", "d", "e"]);
// Under its other spelling `c` counts as had.
assert_eq!(steps_run(5, Some(&["old-c".to_string()])), ["d", "e"]);
// A list beside a hand-written format means nothing.
assert_eq!(steps_run(2, Some(&["d".to_string()])), ["2to3", "a", "c", "d", "e"]);
let mut v = json!({});
assert_eq!(FAMILY.upgrade(&mut v, 0, None), Err(AppError("not a App design (format 0)".into())));
}
#[test]
fn a_newer_file_is_told_by_its_number_or_an_unknown_change() {
assert_eq!(FAMILY.newer(6, None), None);
assert_eq!(FAMILY.newer(6, Some(&["d".to_string()])), None);
assert_eq!(FAMILY.newer(7, None), Some(Newer { format: 7, reads_up_to: 6, unknown_changes: Vec::new() }));
assert_eq!(FAMILY.newer(6, Some(&["z".to_string()])), Some(Newer { format: 6, reads_up_to: 6, unknown_changes: vec!["z".to_string()] }));
assert_eq!(FAMILY.newer(5, Some(&["old-c".to_string()])), None, "an alias of a known change");
assert_eq!(FAMILY.newer(4, Some(&["old-c".to_string()])), Some(Newer { format: 4, reads_up_to: 6, unknown_changes: vec!["old-c".to_string()] }));
assert_eq!(FAMILY.newer(2, Some(&["z".to_string()])), None, "a list beside a hand-written format means nothing");
}
-401
View File
@@ -1,401 +0,0 @@
//! A file a newer build saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file ([`crate::Family::newer`]). It may use nothing this build lacks, so the app
//! reads it as usual. When part of it can't be read, that part stays out of the way and the rest
//! opens: the app reads the elements of its lists it can ([`NewerFile::keep_element`]), then any
//! other top-level section it can't read is left out whole ([`leave_out_unreadable`],
//! [`read_around`]), and a field it doesn't know, which typed reading would drop, is noticed by
//! comparing the file with what was read ([`NewerFile::compare`]).
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`NewerFile::restore_into`]), as they came. What can't be put back because the file was
//! changed around it is what `restore_into` returns.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::Newer;
/// The most sections left out one at a time to find the one a file can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The app, as the notice names it.
pub app: &'static str,
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a container this build has no use for: name and bytes. A blob only one of the
/// kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// A file of `app` that `newer` says a newer build saved, nothing left out of it yet.
pub fn new(app: &'static str, newer: &Newer) -> NewerFile {
NewerFile {
app,
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerFile::default()
}
}
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let (app, reads, format) = (self.app, self.reads_up_to, self.format);
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!("Saved by a newer {app} (format {format} with `{change}`, which this version doesn't know). If something's missing, update.")
}
_ => format!("Saved by a newer {app} (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the file's own.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value_mentions(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value_mentions(&s.value, text))
|| self.elements.iter().any(|(_, v)| value_mentions(v, text))
}
/// Say that a section the reader couldn't finish reading is left out. `empty` is what the
/// app writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
/// Say that an element of the top-level list `key` is left out, as `note` names it; it goes
/// back at the end of the list when the file is saved.
pub fn keep_element(&mut self, key: &str, element: Value, note: String) {
self.not_understood.push(note);
self.elements.push((key.into(), element));
}
/// Compare the file as it reads (`file`, after the same upgrade as what was read) with what was
/// read of it (`known`, its typed record written out): what `known` lacks is kept and named.
/// An object whose `type` is `placeholder_tag` (an element the app kept whole) and the fields
/// in `container_only` (what a container adds beside the file's own) are not compared; nor
/// are the top level's `format` and `format_pending`.
pub fn compare(&mut self, file: &Value, known: &Value, placeholder_tag: &str, container_only: &[&str]) {
let mut paths = Vec::new();
self.kept = vanished(file, known, "", &mut paths, placeholder_tag, container_only);
self.not_understood.extend(paths);
for section in &mut self.sections {
section.empty = known.get(&section.key).cloned();
}
}
/// Write back what was kept of the file into `value`, the file as this build writes it.
/// Returns what couldn't go back, because the file was changed around it.
pub fn restore_into(&self, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
if let Some(kept) = &self.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &self.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &self.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
}
fn value_mentions(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value_mentions(x, text)),
Value::Object(o) => o.values().any(|x| value_mentions(x, text)),
_ => false,
}
}
/// The first line of a reader's error, without the list of every kind it knows, and without the
/// `"<noun> file: "` its reader starts it with.
pub fn short<'a>(why: &'a str, noun: &str) -> &'a str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix(&format!("{noun} file: ")).unwrap_or(why)
}
/// Leave a section out and keep it. A section the file needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, noun: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why, noun)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// Every top-level section of `top` that doesn't read by itself (`piece`, the app's reading of a
/// file holding just that section) is left out and kept in `file`. `noun` is what a file of the
/// app's is called, which its reader's errors start with.
pub fn leave_out_unreadable<E: std::fmt::Display>(
top: &mut Map<String, Value>,
file: &mut NewerFile,
noun: &str,
piece: impl Fn(&str, &Value) -> Result<(), E>,
) {
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), noun, file);
}
}
}
/// Read `top` with `attempt`, leaving out the sections it can't be read with: if it doesn't read,
/// each section but the `core` ones (which the file can't be read without) is tried left out alone,
/// and as a last resort every one of them is. What is left out is kept in `file`. The error is the
/// first attempt's, when even the core alone doesn't read.
pub fn read_around<D, E: std::fmt::Display>(
top: &mut Map<String, Value>,
file: &mut NewerFile,
core: &[&str],
noun: &str,
attempt: impl Fn(&Map<String, Value>) -> Result<D, E>,
) -> Result<D, String> {
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why.to_string(),
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !core.contains(&k.as_str())).cloned().collect();
// Each try reads the whole file: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first, noun, file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| core.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first, noun, file);
}
Ok(doc)
}
Err(_) => Err(first),
}
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
pub fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>, placeholder_tag: &str, container_only: &[&str]) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(placeholder_tag) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if container_only.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths, placeholder_tag, container_only) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths, placeholder_tag, container_only) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
#[cfg(test)]
#[path = "newer_tests.rs"]
mod tests;
-48
View File
@@ -1,48 +0,0 @@
use super::*;
fn newer() -> Newer {
Newer { format: 9, reads_up_to: 8, unknown_changes: Vec::new() }
}
/// What typed reading dropped is named and goes back on saving, where it was.
#[test]
fn dropped_fields_are_named_and_put_back() {
let file = json!({"name": "A", "format": "app/9", "items": [{"id": 1, "size": 2, "colour": "red"}], "extra": {"x": 1}});
let known = json!({"name": "A", "items": [{"id": 1, "size": 2}]});
let mut said = NewerFile::new("App", &newer());
said.compare(&file, &known, "placeholder", &[]);
assert_eq!(said.not_understood, ["field `extra`", "field `items[1].colour`"]);
let mut saved = json!({"name": "A", "items": [{"id": 1, "size": 3}]});
assert!(said.restore_into(&mut saved).is_empty());
assert_eq!(saved, json!({"name": "A", "items": [{"id": 1, "size": 3, "colour": "red"}], "extra": {"x": 1}}));
assert_eq!(said.notice(), "Saved by a newer App (format 9, this version reads up to 8). If something's missing, update.");
}
/// A section that can't be read is left out, kept, and goes back while it is unchanged here.
#[test]
fn unreadable_sections_are_left_out_and_kept() {
let mut top = match json!({"name": "A", "good": 1, "bad": 2}) {
Value::Object(m) => m,
_ => unreachable!(),
};
let mut said = NewerFile::new("App", &newer());
leave_out_unreadable(&mut top, &mut said, "design", |key: &str, _: &Value| if key == "bad" { Err("design file: no") } else { Ok(()) });
assert!(!top.contains_key("bad"));
assert_eq!(said.not_understood, ["section `bad` (no)"]);
let read =
read_around(
&mut top,
&mut said,
&["name"],
"design",
|top: &Map<String, Value>| {
if top.contains_key("good") { Err("design file: clash") } else { Ok(top.len()) }
},
);
assert_eq!(read, Ok(1));
assert_eq!(said.not_understood, ["section `bad` (no)", "section `good` (clash)"]);
let mut saved = json!({"name": "A"});
said.compare(&saved.clone(), &saved.clone(), "placeholder", &[]);
assert!(said.restore_into(&mut saved).is_empty());
assert_eq!(saved, json!({"name": "A", "good": 1, "bad": 2}));
}
+7 -20
View File
@@ -1,13 +1,13 @@
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, one_compile_at_a_time, placeholder_bind, scopes, warm_up};
use solidcraft_render::job::{CancelToken, DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
use solidcraft_render::job::{DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
use std::sync::atomic::{AtomicBool, Ordering};
use wgpu::util::DeviceExt;
pub(super) const GUIDE_BYTES_PER_PIXEL: u64 = 96; // two complete surface guides
pub(super) const BYTES_PER_PIXEL: u64 = GUIDE_BYTES_PER_PIXEL + 32; // two colour vec4s
const FILTER: &str = include_str!("denoise.wgsl");
pub(super) const GUIDES: &str = include_str!("denoise_guides.wgsl");
const GUIDES: &str = include_str!("denoise_guides.wgsl");
#[cfg(test)]
#[path = "denoise_tests.rs"]
mod tests;
@@ -85,21 +85,10 @@ impl Pipelines {
Ok(Self { guide, filter, classify, filter_layout })
}
// The guide program inlines the path tracer's BVH walks, so it compiles under the
// process-wide lock of the path programs (#6706), never beside one of them. As for a scene
// program, the lock is held until the pipelines are published: a job that waited for another
// job's compile then uses its pipelines instead of compiling them again, and a job cancelled
// while it waited returns before compiling. A compile that ran is kept for the next job.
fn shared<'a>(context: &'a Context, cancel: &CancelToken) -> Result<&'a Self> {
fn shared(context: &Context) -> Result<&Self> {
if let Some(pipelines) = context.denoise.get() {
return Ok(pipelines);
}
cancel.check()?;
let _compiling = one_compile_at_a_time();
if let Some(pipelines) = context.denoise.get() {
return Ok(pipelines);
}
cancel.check()?;
let built = Self::new(context)?;
Ok(context.denoise.get_or_init(|| built))
}
@@ -139,7 +128,7 @@ impl Resources {
size: u64,
adaptive_preview: bool,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None, &CancelToken::default())
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None)
}
pub(super) fn new_for_layout(
@@ -149,9 +138,8 @@ impl Resources {
accumulation: &wgpu::Buffer,
adaptive_preview: bool,
layout: super::image_pages::Layout,
cancel: &CancelToken,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout), cancel)
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout))
}
fn build(
@@ -162,9 +150,8 @@ impl Resources {
size: u64,
adaptive_preview: bool,
layout: Option<super::image_pages::Layout>,
cancel: &CancelToken,
) -> Result<Self> {
let pipelines = Pipelines::shared(context, cancel)?;
let pipelines = Pipelines::shared(context)?;
let device = &context.device;
let error_scopes = scopes(device);
let buffer = |label, bytes| {
+70 -89
View File
@@ -10,7 +10,6 @@ struct GuideInterface {
reflected: vec3<f32>, transmitted: vec3<f32>, fresnel: vec3<f32>,
};
const GUIDE_MISS: u32 = 0xffffffffu;
const GUIDE_CAMERA: u32 = 3u;
// Zero body tags bypass filtering. Store the exact early-return reason in the
// otherwise-unused albedo lane; positive/negative tags retain their old layout.
fn guide_bypass(base:u32,reason:u32) {
@@ -109,39 +108,77 @@ fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
let xy=vec2<f32>(f32(pixel%u.image.x)+0.5,f32(pixel/u.image.x)+0.5);
let uv=vec2<f32>(xy.x/f32(u.image.x)*2.0-1.0,1.0-xy.y/f32(u.image.y)*2.0);
let camera=primary_ray(uv,vec2<f32>(0.0));
// Native compilers inline every call site, so the camera ray and its optical
// continuations share one walk, one emitter test and one surface look. The
// camera ray has no low origin: the walk then takes its ordinary candidate
// branch, the arithmetic of trace(), and the emitter test is that of
// emitter_hit_bounded(), both on the same values as a separate call.
var ray=GuideRay(PrecisePoint(camera.origin,vec3<f32>(0.0)),camera.direction,GUIDE_MISS,u.lighting.x);
// Stage GUIDE_CAMERA is the camera ray. Stage 0 is the reflection endpoint,
// 1 is the same closed body's exit, and 2 is the forward endpoint.
var stage=GUIDE_CAMERA;
var t:Triangle;
var primary:Hit;
var geometric=vec3<f32>(0.0);
let primary=trace(camera.origin,camera.direction,false);
if u.environment.w>0.0 && emitter_hit_bounded(camera.origin,camera.direction,u.lighting.x).distance<primary.distance {guide_bypass(base,4u);return;}
if primary.triangle==GUIDE_MISS {guide_bypass(base,3u);return;}
if u.volume_min.w>0.0 {guide_bypass(base,8u);return;}
let t=triangle(primary.triangle);
let position=surface_point(t,primary.bary);
let outward=guide_outward(t);
let entering=dot(outward,-camera.direction)>0.0;
let geometric=select(-outward,outward,entering);
let primary_footprint=guide_footprint(guide_depth(position));
let primary_travel=length(position-camera.origin);
let closed=t.base.w>0.0 && t.parameters.w<0.0;
var reflected=vec3<f32>(0.0);
var transmitted=vec3<f32>(0.0);
var has_reflection=false;
var has_transmission=false;
var forward_stage=2u;
var primary_footprint=0.0;
var primary_travel=0.0;
if t.base.w<=0.0 {
let look=guide_look(t,primary,camera.direction,position,primary_travel,0u);
if !guide_sharp(t,look) {guide_write(base,t.optics.w,look,position,primary_footprint);return;}
if look.metallic<0.99 || look.roughness>0.05 || t.coat.x>0.0 {guide_bypass(base,5u);return;}
reflected=reflect(camera.direction,look.normal);
has_reflection=true;
} else {
if any(t.glass!=vec4<f32>(0.0)) {guide_bypass(base,5u);return;}
if t.interior.x!=0.0 {guide_bypass(base,8u);return;}
if t.interior.w!=0.0 {guide_bypass(base,6u);return;}
if closed && !entering {guide_bypass(base,6u);return;}
let bend=guide_interface(t,primary.bary,camera.direction,geometric,1.0,t.parameters.z);
reflected=bend.reflected;
let f=bend.fresnel;
has_reflection=max(f.x,f.y)>0.0;
has_transmission=f.z>=0.0;
transmitted=bend.transmitted;
if !closed && has_transmission {
transmitted=camera.direction;
// Use the same sheet weights without calling the transport helper
// that can write a failure counter on a tangent absorbing sheet.
if t.parameters.w>0.0 && !(f.z>0.0) {guide_bypass(base,10u);return;}
var attenuation=vec3<f32>(1.0);
if t.parameters.w>0.0 {attenuation=exp(-t.optics.xyz*(t.parameters.w/f.z));}
has_transmission=any(sheet_polarization(f.x,attenuation).transmission+sheet_polarization(f.y,attenuation).transmission>vec3<f32>(0.0));
}
}
if t.optics.w<1.0 || t.optics.w>511.0 {guide_bypass(base,15u);return;}
if u.eye.w>0.5 && u.right.w>0.0 {guide_bypass(base,7u);return;}
if u.sampling.y<2u || (closed && has_transmission && u.sampling.y<3u) {guide_bypass(base,9u);return;}
if !has_reflection && !has_transmission {guide_bypass(base,10u);return;}
if (has_reflection && dot(reflected,geometric)<=0.000001) || (has_transmission && dot(transmitted,geometric)>=-0.000001) {guide_bypass(base,10u);return;}
// A certified root closed entry proves vacuum along this primary segment.
// A mirror or sheet can be hit first even when a nested camera starts inside glass.
if !closed && !guide_vacuum_origin(camera) {guide_bypass(base,6u);return;}
var reflected_body=0u;
var transmitted_body=0u;
var reflected_look:GuideLook;
var reflected_position=vec3<f32>(0.0);
var travel=0.0;
var bounce=0u;
for (var event=0u;event<4u;event+=1u) {
let first=stage==GUIDE_CAMERA;
let hit=trace_precise_within(ray.point.hi,ray.point.lo,ray.direction,false,ray.excluded,ray.minimum,stage==1u,FAR,-1.0);
// Stage 0 is the reflection endpoint, 1 is the same closed body's exit,
// and 2 is the forward endpoint. One call site bounds the extra BVH work.
let forward_stage=select(2u,1u,closed);
var stage=select(forward_stage,0u,has_reflection);
var ray=guide_launch(t,primary,geometric,select(transmitted,reflected,has_reflection));
var travel=primary_travel;
var bounce=1u;
for (var event=0u;event<3u;event+=1u) {
let hit=trace_precise(ray.point.hi,ray.point.lo,ray.direction,false,ray.excluded,ray.minimum,stage==1u);
// A secondary optical branch has no surface correspondence. The primary
// pixel is still on glass/mirror, rather than direct background/emission.
if u.environment.w>0.0 && emitter_hit_precise(ray.point.hi,ray.point.lo,ray.direction,ray.minimum).distance<hit.distance {guide_bypass(base,select(0u,4u,first));return;}
if hit.triangle==GUIDE_MISS {guide_bypass(base,select(0u,3u,first));return;}
if first && u.volume_min.w>0.0 {guide_bypass(base,8u);return;}
if u.environment.w>0.0 && emitter_hit_precise(ray.point.hi,ray.point.lo,ray.direction,ray.minimum).distance<hit.distance {guide_bypass(base,0u);return;}
if hit.triangle==GUIDE_MISS {guide_bypass(base,0u);return;}
let next=triangle(hit.triangle);
if !first {travel+=hit.distance;}
travel+=hit.distance;
if stage==1u {
let normal=-guide_outward(next);
if next.optics.w!=t.optics.w || !guide_smooth_glass(next) || next.parameters.w>=0.0 || dot(normal,ray.direction)>=-0.000001 {guide_bypass(base,11u);return;}
@@ -153,72 +190,16 @@ fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
bounce=2u;
continue;
}
if !first {
if next.base.w>0.0 {guide_bypass(base,13u);return;}
if next.optics.w<1.0 || next.optics.w>511.0 {guide_bypass(base,15u);return;}
}
let position=surface_point(next,hit.bary);
if first {travel=length(position-camera.origin);}
// Every endpoint is opaque here; only the camera hit can be glass.
var look:GuideLook;
if next.base.w<=0.0 {look=guide_look(next,hit,ray.direction,position,travel,bounce);}
if first {
t=next;
primary=hit;
primary_travel=travel;
let outward=guide_outward(t);
let entering=dot(outward,-camera.direction)>0.0;
geometric=select(-outward,outward,entering);
primary_footprint=guide_footprint(guide_depth(position));
let closed=t.base.w>0.0 && t.parameters.w<0.0;
var reflected=vec3<f32>(0.0);
var has_reflection=false;
if t.base.w<=0.0 {
if !guide_sharp(t,look) {guide_write(base,t.optics.w,look,position,primary_footprint);return;}
if look.metallic<0.99 || look.roughness>0.05 || t.coat.x>0.0 {guide_bypass(base,5u);return;}
reflected=reflect(camera.direction,look.normal);
has_reflection=true;
} else {
if any(t.glass!=vec4<f32>(0.0)) {guide_bypass(base,5u);return;}
if t.interior.x!=0.0 {guide_bypass(base,8u);return;}
if t.interior.w!=0.0 {guide_bypass(base,6u);return;}
if closed && !entering {guide_bypass(base,6u);return;}
let bend=guide_interface(t,primary.bary,camera.direction,geometric,1.0,t.parameters.z);
reflected=bend.reflected;
let f=bend.fresnel;
has_reflection=max(f.x,f.y)>0.0;
has_transmission=f.z>=0.0;
transmitted=bend.transmitted;
if !closed && has_transmission {
transmitted=camera.direction;
// Use the same sheet weights without calling the transport helper
// that can write a failure counter on a tangent absorbing sheet.
if t.parameters.w>0.0 && !(f.z>0.0) {guide_bypass(base,10u);return;}
var attenuation=vec3<f32>(1.0);
if t.parameters.w>0.0 {attenuation=exp(-t.optics.xyz*(t.parameters.w/f.z));}
has_transmission=any(sheet_polarization(f.x,attenuation).transmission+sheet_polarization(f.y,attenuation).transmission>vec3<f32>(0.0));
}
}
if t.optics.w<1.0 || t.optics.w>511.0 {guide_bypass(base,15u);return;}
if u.eye.w>0.5 && u.right.w>0.0 {guide_bypass(base,7u);return;}
if u.sampling.y<2u || (closed && has_transmission && u.sampling.y<3u) {guide_bypass(base,9u);return;}
if !has_reflection && !has_transmission {guide_bypass(base,10u);return;}
if (has_reflection && dot(reflected,geometric)<=0.000001) || (has_transmission && dot(transmitted,geometric)>=-0.000001) {guide_bypass(base,10u);return;}
// A certified root closed entry proves vacuum along this primary segment.
// A mirror or sheet can be hit first even when a nested camera starts inside glass.
if !closed && !guide_vacuum_origin(camera) {guide_bypass(base,6u);return;}
forward_stage=select(2u,1u,closed);
stage=select(forward_stage,0u,has_reflection);
ray=guide_launch(t,primary,geometric,select(transmitted,reflected,has_reflection));
bounce=1u;
continue;
}
if guide_sharp(next,look) {guide_bypass(base,14u);return;}
if next.base.w>0.0 {guide_bypass(base,13u);return;}
if next.optics.w<1.0 || next.optics.w>511.0 {guide_bypass(base,15u);return;}
let endpoint_position=surface_point(next,hit.bary);
let endpoint_look=guide_look(next,hit,ray.direction,endpoint_position,travel,bounce);
if guide_sharp(next,endpoint_look) {guide_bypass(base,14u);return;}
if stage==0u {
reflected_body=u32(next.optics.w);
if has_transmission {
reflected_look=look;
reflected_position=position;
reflected_look=endpoint_look;
reflected_position=endpoint_position;
ray=guide_launch(t,primary,geometric,transmitted);
stage=forward_stage;
travel=primary_travel;
@@ -226,7 +207,7 @@ fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
}
} else {transmitted_body=u32(next.optics.w);}
let identity=0xa0000000u|(u32(t.optics.w)<<18u)|(reflected_body<<9u)|transmitted_body;
guide_write(base,bitcast<f32>(identity),look,position,primary_footprint);
guide_write(base,bitcast<f32>(identity),endpoint_look,endpoint_position,primary_footprint);
if reflected_body!=0u && transmitted_body!=0u {
guide_write(base+3u,f32(reflected_body),reflected_look,reflected_position,primary_footprint);
}
-29
View File
@@ -186,35 +186,6 @@ fn f64_atrous_preserves_body_normal_depth_and_albedo_edges() {
assert!(filtered.iter().zip(&pixels).all(|(a, b)| (a[0] - b[0]).abs() < 1e-12), "edge {edge}");
}
}
// Native compilers inline every call site, and their cost grows faster than the inlined size
// (#14105). The camera ray and its optical continuations share one walk; the camera origin
// query is the other.
#[test]
fn guides_inline_two_bvh_walks() {
fn walks(module: &naga::Module, block: &naga::Block) -> usize {
block
.iter()
.map(|statement| match statement {
naga::Statement::Call { function, .. } => {
let callee = &module.functions[*function];
let walk = matches!(callee.name.as_deref(), Some("trace_precise_within" | "trace_ordered_within" | "origin_boundary"));
usize::from(walk) + walks(module, &callee.body)
}
naga::Statement::Block(inner) => walks(module, inner),
naga::Statement::If { accept, reject, .. } => walks(module, accept) + walks(module, reject),
naga::Statement::Switch { cases, .. } => cases.iter().map(|case| walks(module, &case.body)).sum(),
naga::Statement::Loop { body, continuing, .. } => walks(module, body) + walks(module, continuing),
_ => 0,
})
.sum()
}
let module = naga::front::wgsl::parse_str(&format!("{SHADER}\n{GUIDES}")).unwrap();
let guides = module.entry_points.iter().find(|entry| entry.name == "denoise_guides").unwrap();
let inlined = walks(&module, &guides.function.body);
// Exactly two, not at most two: if a walk function is renamed, the names above match nothing
// and an upper bound would pass on zero walks.
assert_eq!(inlined, 2, "BVH walks inlined into `denoise_guides`");
}
fn context() -> Option<Arc<Context>> {
let (_, device, queue) = match open_headless() {
Ok(v) => v,
+23 -29
View File
@@ -65,7 +65,7 @@ const SHADER: &str = path_shader!(16);
const SINGLE_MEDIUM_SHADER: &str = path_shader!(1);
const MAX_TRIANGLES: u64 = MAX_GPU_PATH_TRIANGLES;
const PATH_WORKGROUP_SIZE: u32 = 32;
const SCENE_PIPELINE_CACHE_SLOTS: usize = 2;
const SCENE_PIPELINE_CACHE_SLOTS: usize = 5;
const CLAMPED_PIPELINE_CACHE_SLOTS: usize = SCENE_PIPELINE_CACHE_SLOTS + 1;
const PARAMETER_BYTES: usize = 2096;
// Five vec4s per source; exact device+upload growth is additional to v2's budget.
@@ -206,10 +206,10 @@ struct Context {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
// Plain opaque and unmapped glass are ready before the host is published.
// The mapped program is prepared lazily in the remaining slot.
// Plain opaque and non-nested glass are ready before the host is published.
// The four map/nested variants are prepared lazily in the remaining slots.
scene_pipelines: [std::sync::OnceLock<wgpu::ComputePipeline>; SCENE_PIPELINE_CACHE_SLOTS],
// The same three programs with the indirect clamp compiled in, each built and warmed by the
// The same six programs with the indirect clamp compiled in, each built and warmed by the
// first job that asks for it. Jobs without a clamp never compile or use them.
clamped_pipelines: [std::sync::OnceLock<wgpu::ComputePipeline>; CLAMPED_PIPELINE_CACHE_SLOTS],
// Built and warmed by the first denoised job, then shared by later ones.
@@ -240,27 +240,27 @@ impl Context {
}
// A job with an indirect clamp (JobOptions::clamp_indirect) uses its scene's program with
// the clamp compiled in; every other job keeps the programs without it.
fn scene_pipeline_slot(&self, opaque: bool, maps: bool, clamp: bool) -> Option<&std::sync::OnceLock<wgpu::ComputePipeline>> {
fn scene_pipeline_slot(&self, opaque: bool, maps: bool, nested: bool, clamp: bool) -> Option<&std::sync::OnceLock<wgpu::ComputePipeline>> {
if clamp {
return self.clamped_pipelines.get(clamped_pipeline_variant(opaque, maps));
return self.clamped_pipelines.get(clamped_pipeline_variant(opaque, maps, nested));
}
scene_pipeline_variant(opaque, maps).and_then(|index| self.scene_pipelines.get(index))
scene_pipeline_variant(opaque, maps, nested).and_then(|index| self.scene_pipelines.get(index))
}
#[cfg(test)]
fn prepared_scene_pipeline(&self, opaque: bool, maps: bool, clamp: bool) -> Result<&wgpu::ComputePipeline> {
if !clamp && scene_pipeline_variant(opaque, maps).is_none() {
fn prepared_scene_pipeline(&self, opaque: bool, maps: bool, nested: bool, clamp: bool) -> Result<&wgpu::ComputePipeline> {
if !clamp && scene_pipeline_variant(opaque, maps, nested).is_none() {
return Ok(&self.pipeline);
}
self.scene_pipeline_slot(opaque, maps, clamp).and_then(std::sync::OnceLock::get).ok_or_else(|| gpu("captured scene has no prepared pipeline"))
self.scene_pipeline_slot(opaque, maps, nested, clamp)
.and_then(std::sync::OnceLock::get)
.ok_or_else(|| gpu("captured scene has no prepared pipeline"))
}
// A job cancelled while it waited for another compile returns before starting its own; a
// compile that ran is warmed up and kept for the next job.
fn prepare_scene_pipeline(&self, opaque: bool, maps: bool, clamp: bool, cancel: &CancelToken) -> Result<&wgpu::ComputePipeline> {
fn prepare_scene_pipeline(&self, opaque: bool, maps: bool, nested: bool, clamp: bool) -> Result<&wgpu::ComputePipeline> {
self.ensure_available()?;
if !clamp && scene_pipeline_variant(opaque, maps).is_none() {
if !clamp && scene_pipeline_variant(opaque, maps, nested).is_none() {
return Ok(&self.pipeline);
}
let slot = self.scene_pipeline_slot(opaque, maps, clamp).ok_or_else(|| gpu("scene pipeline variant exceeds its finite cache"))?;
let slot = self.scene_pipeline_slot(opaque, maps, nested, clamp).ok_or_else(|| gpu("scene pipeline variant exceeds its finite cache"))?;
if let Some(pipeline) = slot.get() {
return Ok(pipeline);
}
@@ -268,7 +268,6 @@ impl Context {
if let Some(pipeline) = slot.get() {
return Ok(pipeline);
}
cancel.check()?;
let scopes = scopes(&self.device);
// RADV's one-medium opaque specialization corrupts shading normals
// after texture sampling (the mapped Lambertian plane loses its rim
@@ -388,8 +387,8 @@ impl Executor {
clamped_pipelines: std::array::from_fn(|_| std::sync::OnceLock::new()),
});
// The original host prepared glass before advertising readiness. Keep the
// common unmapped program out of the first image job's work.
context.prepare_scene_pipeline(false, false, false, &CancelToken::default())?;
// common no-map/non-nested program out of the first image job's work.
context.prepare_scene_pipeline(false, false, false, false)?;
Ok(Self { context })
}
}
@@ -547,17 +546,13 @@ fn scene_shader(opaque: bool) -> &'static str {
if opaque { SINGLE_MEDIUM_SHADER } else { SHADER }
}
/// A captured scene's program slot, or None for the eager opaque/no-map program. A slot is one
/// compiled program: prepare_scene_pipeline builds scene_shader(opaque && !maps) with only
/// OPAQUE_SCENE, HAS_TEXTURES and the clamp, so an opaque scene with maps shares the mapped glass
/// program, and nested media, a uniform, share the program of disjoint media.
fn scene_pipeline_variant(opaque: bool, maps: bool) -> Option<usize> {
if opaque && !maps { None } else { Some(usize::from(maps)) }
fn scene_pipeline_variant(opaque: bool, maps: bool, nested: bool) -> Option<usize> {
if opaque { maps.then_some(0) } else { Some(1 + 2 * usize::from(maps) + usize::from(nested)) }
}
/// The clamped program's slot: all three scene programs, the eager opaque/no-map one included.
fn clamped_pipeline_variant(opaque: bool, maps: bool) -> usize {
scene_pipeline_variant(opaque, maps).map_or(0, |index| index + 1)
/// The clamped program's slot: all six scene programs, the eager opaque/no-map one included.
fn clamped_pipeline_variant(opaque: bool, maps: bool, nested: bool) -> usize {
scene_pipeline_variant(opaque, maps, nested).map_or(0, |index| index + 1)
}
fn use_opaque_pipeline(scene: &ProductScene, options: JobOptions) -> bool {
@@ -1120,7 +1115,7 @@ impl Task {
} else if let Some(resources) = &photons {
resources.render.clone()
} else {
context.prepare_scene_pipeline(opaque_scene, has_textures, clamp, &cancel)?.clone()
context.prepare_scene_pipeline(opaque_scene, has_textures, options.nested_media, clamp)?.clone()
};
let optical_resolve = optical_pipelines.map(|pipelines| pipelines.resolve);
cancel.check()?;
@@ -1189,7 +1184,6 @@ impl Task {
&accumulation,
options.adaptive.is_some(),
page_layout,
&cancel,
)?)
} else {
None
+125 -205
View File
@@ -276,18 +276,16 @@ fn fp_sub_bits(left: u32, right: u32) -> u32 {
// RTE for a general floating subtraction, so approximate differences must retain
// the integer implementation. The fast case has an exactly representable normal
// result, independent of rounding direction and permitted subnormal flushing.
// `exact` is false where fp_sub_barrier_bits takes the integer subtraction.
struct ExactDifference { bits: u32, exact: bool };
fn fp_sub_exact(left: u32, right: u32) -> ExactDifference {
fn fp_sub_barrier_bits(left: u32, right: u32) -> u32 {
let a=left&0x7fffffffu;
let b=right&0x7fffffffu;
if a>=0x7f800000u || b>=0x7f800000u { return ExactDifference(0u,false); }
if a>=0x7f800000u || b>=0x7f800000u { return fp_sub_bits(left,right); }
if a==0u {
if b==0u { return ExactDifference(left&(right^0x80000000u)&0x80000000u,true); }
return ExactDifference(right^0x80000000u,true);
if b==0u { return left&(right^0x80000000u)&0x80000000u; }
return right^0x80000000u;
}
if b==0u { return ExactDifference(left,true); }
if left==right { return ExactDifference(0u,true); }
if b==0u { return left; }
if left==right { return 0u; }
let small=min(a,b);
let large=max(a,b);
let small_exponent=small>>23u;
@@ -298,50 +296,25 @@ fn fp_sub_exact(left: u32, right: u32) -> ExactDifference {
// Encoded exponent 24 has spacing 2^-126. Any distinct pair admitted here
// therefore has a normal difference, including the closest adjacent pair.
if same_sign && small_exponent>=24u && within_two {
return ExactDifference(bitcast<u32>(bitcast<f32>(left)-bitcast<f32>(right)),true);
return bitcast<u32>(bitcast<f32>(left)-bitcast<f32>(right));
}
return ExactDifference(0u,false);
}
fn fp_sub_barrier_bits(left: u32, right: u32) -> u32 {
let difference=fp_sub_exact(left,right);
if difference.exact { return difference.bits; }
return fp_sub_bits(left,right);
}
fn anchored_delta_bits(a: u32, a_low: u32, b: u32, b_low: u32) -> u32 {
// Step 0 is offset=a_low-b_low and step 1 anchor=a-b, each exactly as
// fp_sub_barrier_bits; step 2 is anchor+offset. Native compilers inline
// every call, so the three share one integer addition.
var offset=0u;
var anchor=0u;
var bits=0u;
for (var step=0u; step<3u; step+=1u) {
var left=anchor;
var right=offset;
var integer=true;
if step<2u {
left=select(a_low,a,step==1u);
right=select(b_low,b,step==1u);
let difference=fp_sub_exact(left,right);
bits=difference.bits;
integer=!difference.exact;
// fp_sub_bits(left,right) is fp_add_bits(left,right^sign).
right^=0x80000000u;
}
// At step 2 this nonlinear integer operation is the reassociation barrier.
// Keep it even when both component differences used exact hardware subtraction.
if integer { bits=fp_add_bits(left,right); }
if step==0u {
offset=bits;
// Identical finite anchors have +0 difference. Adding it preserves a
// nonzero offset; either sign of a zero offset becomes +0.
if a==b && (a&0x7fffffffu)<0x7f800000u { return select(offset,0u,(offset&0x7fffffffu)==0u); }
} else if step==1u {
anchor=bits;
// IEEE RTE addition of two zeros retains -0 only if both are -0.
if (offset&0x7fffffffu)==0u { return select(anchor,anchor&offset,(anchor&0x7fffffffu)==0u); }
}
let offset=fp_sub_barrier_bits(a_low,b_low);
// Identical finite anchors have +0 difference. Adding it preserves a
// nonzero offset; either sign of a zero offset becomes +0.
if a==b && (a&0x7fffffffu)<0x7f800000u {
return select(offset,0u,(offset&0x7fffffffu)==0u);
}
return bits;
let anchor=fp_sub_barrier_bits(a,b);
if (offset&0x7fffffffu)==0u {
// IEEE RTE addition of two zeros retains -0 only if both are -0.
return select(anchor,anchor&offset,(anchor&0x7fffffffu)==0u);
}
// This nonlinear integer operation is the reassociation barrier. Keep it
// even when both component differences used exact hardware subtraction.
return fp_add_bits(anchor,offset);
}
fn anchored_delta_component(a: f32, a_low: f32, b: f32, b_low: f32) -> f32 {
return bitcast<f32>(anchored_delta_bits(bitcast<u32>(a),bitcast<u32>(a_low),bitcast<u32>(b),bitcast<u32>(b_low)));
@@ -350,10 +323,7 @@ fn anchored_delta_component(a: f32, a_low: f32, b: f32, b_low: f32) -> f32 {
struct PrecisePoint { hi: vec3<f32>, lo: vec3<f32> };
fn precise_point_delta(a: vec3<f32>, a_low: vec3<f32>, b: vec3<f32>, b_low: vec3<f32>) -> vec3<f32> {
if all(a_low==vec3<f32>(0.0)) && all(b_low==vec3<f32>(0.0)) { return a-b; }
// The three components share one inlined integer delta.
var delta=vec3<f32>(0.0);
for (var k=0u; k<3u; k+=1u) { delta[k]=anchored_delta_component(a[k],a_low[k],b[k],b_low[k]); }
return delta;
return vec3<f32>(anchored_delta_component(a.x,a_low.x,b.x,b_low.x),anchored_delta_component(a.y,a_low.y,b.y,b_low.y),anchored_delta_component(a.z,a_low.z,b.z,b_low.z));
}
fn ray_segment_precise(o: vec3<f32>, o_low: vec3<f32>, previous: vec3<f32>, previous_low: vec3<f32>, d: vec3<f32>, distance: f32) -> vec3<f32> {
// Subtract the physical origins before adding a possibly tiny ray segment.
@@ -386,8 +356,10 @@ fn triangle_ahead(t: Triangle, o: vec3<f32>, d: vec3<f32>) -> bool {
if any((d==vec3<f32>(0.0))&((o<lo)|(o>hi))) { return false; }
return true;
}
// The same test from the bounds' integer-defined deltas to an anchored origin.
fn deltas_ahead(lo: vec3<f32>, hi: vec3<f32>, d: vec3<f32>) -> bool {
fn triangle_ahead_precise(t: Triangle, o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>) -> bool {
if all(o_low==vec3<f32>(0.0)) { return triangle_ahead(t,o,d); }
let lo=precise_point_delta(min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz)),vec3<f32>(0.0),o,o_low);
let hi=precise_point_delta(max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz)),vec3<f32>(0.0),o,o_low);
if any((lo>=vec3<f32>(0.0))&(d<vec3<f32>(0.0))) || any((hi<=vec3<f32>(0.0))&(d>vec3<f32>(0.0))) { return false; }
if any((d==vec3<f32>(0.0))&((lo>vec3<f32>(0.0))|(hi<vec3<f32>(0.0)))) { return false; }
return true;
@@ -420,6 +392,12 @@ fn split_product(a: f32, b: f32) -> vec2<f32> {
let e3=split_materialize(e2-ah*bl);
return vec2<f32>(p,split_materialize(al*bl-e3));
}
fn split_two_sum(a: f32, b: f32) -> vec2<f32> {
let swap=abs(a)<abs(b);let large=select(a,b,swap);let small=select(b,a,swap);
let sum=split_add(large,small);
let delta=split_materialize(sum-large);
return vec2<f32>(sum,split_materialize(small-delta));
}
fn split_rescale(value: f32, exponent: i32) -> f32 {
if value==0.0 { return value; }
let scaled=split_scaled(value);let combined=scaled.exponent+exponent+1;
@@ -430,43 +408,16 @@ fn split_difference_products(a: f32, b: f32, c: f32, d: f32) -> f32 {
let aa=split_scaled(a);let bb=split_scaled(b);let cc=split_scaled(c);let dd=split_scaled(d);
let p=split_product(aa.value,bb.value);let q=split_product(cc.value,dd.value);
let ep=aa.exponent+bb.exponent;let eq=cc.exponent+dd.exponent;
// Native compilers inline every call, so the possible sums run as steps that
// share one split_add and one split_rescale. Each step is one split_add(x,y):
// 0 the sum of the product that is not zero (negated for q) when the other
// is; 1 the two-sum tail of (pp.y,-qq.y); 2 the two-sum total of (high,tail.x);
// 3 total.y+tail.y; 4 total.x plus that.
var step=1u;
var x=0.0;var y=0.0;
var exponent=0;
var high=0.0;
var tail=vec2<f32>(0.0);var total=vec2<f32>(0.0);
var value=0.0;
if p.x==0.0 || q.x==0.0 {
step=0u;
x=select(p.x,q.x,p.x==0.0);y=select(p.y,q.y,p.x==0.0);exponent=select(ep,eq,p.x==0.0);
} else {
exponent=max(ep,eq);
let pp=ldexp(p,vec2<i32>(ep-exponent));let qq=ldexp(q,vec2<i32>(eq-exponent));
high=split_materialize(pp.x-qq.x);
x=pp.y;y=-qq.y;
}
loop {
// A two-sum adds its operands larger first and keeps the rounding error.
let swap=(step==1u || step==2u) && abs(x)<abs(y);
let large=select(x,y,swap);let small=select(y,x,swap);
let sum=split_add(large,small);
if step==0u { value=select(sum,-sum,p.x==0.0); break; }
if step==4u { value=sum; break; }
if step==3u { x=total.x;y=sum;step=4u;continue; }
let delta=split_materialize(sum-large);
let pair=vec2<f32>(sum,split_materialize(small-delta));
if step==2u { total=pair;x=total.y;y=tail.y;step=3u;continue; }
tail=pair;
if high==0.0 { value=tail.x; break; }
if tail.x==0.0 { value=high; break; }
x=high;y=tail.x;step=2u;
}
return split_rescale(value,exponent);
if p.x==0.0 { return split_rescale(-split_add(q.x,q.y),eq); }
if q.x==0.0 { return split_rescale(split_add(p.x,p.y),ep); }
let exponent=max(ep,eq);
let pp=ldexp(p,vec2<i32>(ep-exponent));let qq=ldexp(q,vec2<i32>(eq-exponent));
let high=split_materialize(pp.x-qq.x);
let tail=split_two_sum(pp.y,-qq.y);
if high==0.0 { return split_rescale(tail.x,exponent); }
if tail.x==0.0 { return split_rescale(high,exponent); }
let total=split_two_sum(high,tail.x);
return split_rescale(split_add(total.x,split_add(total.y,tail.y)),exponent);
}
fn difference_products(a: f32, b: f32, c: f32, d: f32) -> f32 {
let ab=a*b;let cd=c*d;
@@ -478,19 +429,6 @@ fn difference_products(a: f32, b: f32, c: f32, d: f32) -> f32 {
}
return split_difference_products(a,b,c,d);
}
// Signed edge functions of a projected triangle: weight k pairs the two vertices
// after vertex k. Native compilers inline difference_products at every call
// site, so all three weights share one.
fn edge_weights(a: vec2<f32>, b: vec2<f32>, c: vec2<f32>) -> vec3<f32> {
var weights=vec3<f32>(0.0);
for (var k=0u; k<3u; k+=1u) {
// Selected, not indexed: the pairs (b,c), (c,a), (a,b) stay in registers.
let e=select(select(a,c,k==1u),b,k==0u);
let f=select(select(b,a,k==1u),c,k==0u);
weights[k]=difference_products(e.x,f.y,e.y,f.x);
}
return weights;
}
struct Hit { distance: f32, triangle: u32, bary: vec3<f32> };
// A closed continuation that leaves its producing triangle at a shared edge or
// vertex can meet an adjacent face of the same body within the origin's rounding
@@ -515,8 +453,7 @@ fn tie_preferred(t: Triangle, d: vec3<f32>, inside: bool) -> bool {
return front!=inside;
}
fn trace_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool) -> Hit {
// Without a low part, trace_precise_within is the ordered walk: the same
// origin, pad, error bound and candidate test. One walk is inlined here.
if all(o_low==vec3<f32>(0.0)) { return trace_ordered_within(o,d,any_hit,excluded,minimum,inside,FAR,-1.0); }
return trace_precise_within(o,o_low,d,any_hit,excluded,minimum,inside,FAR,-1.0);
}
// Only hits closer than `limit` are reported, and nodes beyond it are culled.
@@ -589,72 +526,85 @@ fn trace_precise_within(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: b
// IDs join only connected, exactly coplanar uploaded f32 triangles
// of one body; curved facets and other bodies remain candidates.
if excluded!=0xffffffffu && geometry[geometry[3]+i]==geometry[geometry[3]+excluded] { continue; }
let t = triangle(i);
let lo=min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz));
let hi=max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz));
// Native compilers inline the candidate arithmetic once per call site,
// so one candidate test serves three policies, each evaluated exactly
// as on its own:
// 0: an origin without residual takes the former ordered test;
// 1: a residual origin's candidate beyond near_radius uses ordinary
// arithmetic from the rounded origin while each decision clears
// its error bound, and otherwise takes
// 2: integer-defined deltas from the anchored origin.
var policy=0u;
if has_origin_residual { policy=select(2u,1u,any(max(lo-rounded,rounded-hi)>vec3<f32>(near_radius))); }
// Each pass ends with break (next candidate) or, once, continue (policy 2).
loop {
var p=array<vec3<f32>,3>(t.p0.xyz-rounded,t.p1.xyz-rounded,t.p2.xyz-rounded);
if policy==0u {
if !triangle_ahead(t,o,d) { break; }
} else if policy==1u {
// The rounded origin is within origin_error of the true one on each
// axis. Bound and edge decisions that close to their threshold could
// flip, while a nearby neighbour across the edge is tested exactly:
// a crossing would fall between them. Such candidates take policy 2.
if any((rounded<=lo-vec3<f32>(origin_error))&(d<vec3<f32>(0.0))) || any((rounded>=hi+vec3<f32>(origin_error))&(d>vec3<f32>(0.0))) { break; }
if any((d==vec3<f32>(0.0))&((rounded<lo-vec3<f32>(origin_error))|(rounded>hi+vec3<f32>(origin_error)))) { break; }
} else {
// Bound deltas first: a candidate that is not ahead is rejected
// before its vertex deltas are formed. The five points lo, hi,
// p0, p1, p2 share one delta call site.
var low=vec3<f32>(0.0);
var ahead=true;
for (var k=0u; k<5u; k+=1u) {
let point=select(select(select(t.p2.xyz,t.p1.xyz,k==3u),t.p0.xyz,k==2u),select(hi,lo,k==0u),k<2u);
let delta=precise_point_delta(point,vec3<f32>(0.0),o,o_low);
if k==0u { low=delta; }
else if k==1u { if !deltas_ahead(low,delta,d) { ahead=false; break; } }
else if k==2u { p[0]=delta; }
else if k==3u { p[1]=delta; }
else { p[2]=delta; }
}
if !ahead { break; }
}
let a=vec2<f32>(p[0][kx]-shear.x*p[0][kz],p[0][ky]-shear.y*p[0][kz]);
let b=vec2<f32>(p[1][kx]-shear.x*p[1][kz],p[1][ky]-shear.y*p[1][kz]);
let c=vec2<f32>(p[2][kx]-shear.x*p[2][kz],p[2][ky]-shear.y*p[2][kz]);
let weights=edge_weights(a,b,c);
if policy==1u && !all(abs(weights)>origin_error*vec3<f32>(length(b-c),length(c-a),length(a-b))) { policy=2u; continue; }
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { break; }
// The ordinary source uses exactly the former ordered candidate
// branch. Sharing only the node walk avoids another inlined BVH.
if !has_origin_residual {
let t = triangle(i);
if !triangle_ahead(t,o,d) { continue; }
let pa=t.p0.xyz-o; let pb=t.p1.xyz-o; let pc=t.p2.xyz-o;
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
let det=weights.x+weights.y+weights.z;
// As before, policy 1 has no det test: each weight cleared a
// nonnegative bound. Nor does it test a repeated crossing.
if det==0.0 && policy!=1u { break; }
if det==0.0 { continue; }
// Retain all three directly evaluated weights: 1-u-v can erase a
// small corner clearance when the other two weights sum to one.
let bary=weights/det;
var depth=0.0;
if policy==2u { depth=surface_ray_depth_precise(t,bary,o,o_low,kz); }
else { depth=surface_ray_depth(t,bary,rounded,kz); }
let depth=surface_ray_depth(t,bary,o,kz);
let distance=depth*shear.z;
let key=depth*order;
if policy!=1u && excluded!=0xffffffffu && repeated_crossing(t,source,d,inside,distance,origin_error) { break; }
if excluded!=0xffffffffu && repeated_crossing(t,source,d,inside,distance,origin_error) { continue; }
if distance > minimum && distance < limit && (key < best || (key == best && tie_preferred(t,d,inside))) {
hit = Hit(distance,i,bary); best=key;
if any_hit || (distance < blocker && (t.base.w<=0.0 || t.coat.w<=0.0)) { return hit; }
}
break;
continue;
}
let t = triangle(i);
let lo=min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz));
let hi=max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz));
let gap=max(lo-rounded,rounded-hi);
if any(gap>vec3<f32>(near_radius)) {
// The rounded origin is within origin_error of the true one on each
// axis. Bound and edge decisions that close to their threshold could
// flip, while a nearby neighbour across the edge is tested exactly:
// a crossing would fall between them. Such candidates take the exact
// path below.
if any((rounded<=lo-vec3<f32>(origin_error))&(d<vec3<f32>(0.0))) || any((rounded>=hi+vec3<f32>(origin_error))&(d>vec3<f32>(0.0))) { continue; }
if any((d==vec3<f32>(0.0))&((rounded<lo-vec3<f32>(origin_error))|(rounded>hi+vec3<f32>(origin_error)))) { continue; }
let qa=t.p0.xyz-rounded; let qb=t.p1.xyz-rounded; let qc=t.p2.xyz-rounded;
let fa=vec2<f32>(qa[kx]-shear.x*qa[kz],qa[ky]-shear.y*qa[kz]);
let fb=vec2<f32>(qb[kx]-shear.x*qb[kz],qb[ky]-shear.y*qb[kz]);
let fc=vec2<f32>(qc[kx]-shear.x*qc[kz],qc[ky]-shear.y*qc[kz]);
let far_weights=vec3<f32>(difference_products(fb.x,fc.y,fb.y,fc.x),difference_products(fc.x,fa.y,fc.y,fa.x),difference_products(fa.x,fb.y,fa.y,fb.x));
let edge_error=origin_error*vec3<f32>(length(fb-fc),length(fc-fa),length(fa-fb));
if all(abs(far_weights)>edge_error) {
if any(far_weights<vec3<f32>(0.0)) && any(far_weights>vec3<f32>(0.0)) { continue; }
let far_det=far_weights.x+far_weights.y+far_weights.z;
let far_bary=far_weights/far_det;
let far_depth=surface_ray_depth(t,far_bary,rounded,kz);
let far_distance=far_depth*shear.z;
let far_key=far_depth*order;
if far_distance > minimum && far_distance < limit && (far_key < best || (far_key == best && tie_preferred(t,d,inside))) {
hit = Hit(far_distance,i,far_bary); best=far_key;
if any_hit || (far_distance < blocker && (t.base.w<=0.0 || t.coat.w<=0.0)) { return hit; }
}
continue;
}
}
if !triangle_ahead_precise(t,o,o_low,d) { continue; }
let pa=precise_point_delta(t.p0.xyz,vec3<f32>(0.0),o,o_low);
let pb=precise_point_delta(t.p1.xyz,vec3<f32>(0.0),o,o_low);
let pc=precise_point_delta(t.p2.xyz,vec3<f32>(0.0),o,o_low);
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
let det=weights.x+weights.y+weights.z;
if det==0.0 { continue; }
// Retain all three directly evaluated weights: 1-u-v can erase a
// small corner clearance when the other two weights sum to one.
let bary=weights/det;
let depth=surface_ray_depth_precise(t,bary,o,o_low,kz);
let distance=depth*shear.z;
let key=depth*order;
if excluded!=0xffffffffu && repeated_crossing(t,source,d,inside,distance,origin_error) { continue; }
if distance > minimum && distance < limit && (key < best || (key == best && tie_preferred(t,d,inside))) {
hit = Hit(distance,i,bary); best=key;
if any_hit || (distance < blocker && (t.base.w<=0.0 || t.coat.w<=0.0)) { return hit; }
}
}
}
@@ -719,7 +669,7 @@ fn trace_ordered_within(o: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
let weights=edge_weights(a,b,c);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
let det=weights.x+weights.y+weights.z;
if det==0.0 { continue; }
@@ -777,7 +727,7 @@ fn origin_on_boundary(t: Triangle, o: vec3<f32>) -> bool {
let x=(axis+1u)%3u;let y=(x+1u)%3u;
let pa=t.p0.xyz-o;let pb=t.p1.xyz-o;let pc=t.p2.xyz-o;
let a=vec2<f32>(pa[x],pa[y]);let b=vec2<f32>(pb[x],pb[y]);let c=vec2<f32>(pc[x],pc[y]);
let weights=edge_weights(a,b,c);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
return !(any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)));
}
fn origin_boundary(o: vec3<f32>, d: vec3<f32>) -> OriginBoundary {
@@ -834,25 +784,10 @@ fn origin_boundary(o: vec3<f32>, d: vec3<f32>) -> OriginBoundary {
// Keep the ray projection homogeneous: a rounded d[kx]/d[kz]
// must not turn an exact uploaded-f32 edge tangent into a crossing.
// All projected coordinates share d[kz], so barycentrics are invariant.
// Steps 0-5 project vertex k/2 on axis kx (even k) or ky (odd k);
// steps 6-8 then form the edge weights as edge_weights does. All
// nine share one inlined difference_products.
var a=vec2<f32>(0.0);var b=vec2<f32>(0.0);var c=vec2<f32>(0.0);
var weights=vec3<f32>(0.0);
for (var k=0u; k<9u; k+=1u) {
var products=vec4<f32>(0.0);
if k<6u {
let point=select(select(pc,pb,k<4u),pa,k<2u);
let axis=select(kx,ky,(k&1u)!=0u);
products=vec4<f32>(point[axis],d[kz],d[axis],point[kz]);
} else {
let e=select(select(a,c,k==7u),b,k==6u);
let f=select(select(b,a,k==7u),c,k==6u);
products=vec4<f32>(e.x,f.y,e.y,f.x);
}
let value=difference_products(products.x,products.y,products.z,products.w);
if k<2u { a[k]=value; } else if k<4u { b[k-2u]=value; } else if k<6u { c[k-4u]=value; } else { weights[k-6u]=value; }
}
let a=vec2<f32>(difference_products(pa[kx],d[kz],d[kx],pa[kz]),difference_products(pa[ky],d[kz],d[ky],pa[kz]));
let b=vec2<f32>(difference_products(pb[kx],d[kz],d[kx],pb[kz]),difference_products(pb[ky],d[kz],d[ky],pb[kz]));
let c=vec2<f32>(difference_products(pc[kx],d[kz],d[kx],pc[kz]),difference_products(pc[ky],d[kz],d[ky],pc[kz]));
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
let det=weights.x+weights.y+weights.z;
if det==0.0 { continue; }
@@ -1149,9 +1084,8 @@ fn direct_light(t: Triangle, position: vec3<f32>, physical: PrecisePoint, o: vec
if !studio || emitter_hit_precise(shadow_origin,shadow_origin_low,shadow_direction,shadow_minimum).distance>=emitter_limit {
visibility = shadow_transmittance(shadow_origin,shadow_origin_low,shadow_direction,shadow_distance,shadow_minimum);
if !OPAQUE_SCENE && studio && u.volume_min.w>0.0 {
// A punctual connection is attenuated along its physical segment.
let attenuation=volume_transmittance_precise(select(shadow_origin,physical.hi,punctual),select(shadow_origin_low,physical.lo,punctual),
select(shadow_direction,sample.direction,punctual),select(shadow_distance,physical_distance,punctual));
var attenuation=volume_transmittance_precise(shadow_origin,shadow_origin_low,shadow_direction,shadow_distance);
if punctual { attenuation=volume_transmittance_precise(physical.hi,physical.lo,sample.direction,physical_distance); }
visibility.transmittance*=attenuation;
visibility.branch_pdf*=attenuation;
}
@@ -1374,18 +1308,11 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
if u.environment.w > 0.0 {
if emitter.distance < hit.distance {
let light = u.area_lights[emitter.index];
// The physical and scatter segments to the emitter share one inlined
// precise subtraction.
var segment=vec3<f32>(0.0);
var scatter_segment=vec3<f32>(0.0);
for (var k=0u; k<2u; k+=1u) {
let delta=ray_segment_precise(o,o_low,select(previous_position,previous_scatter_position,k==1u),
select(previous_position_low,previous_scatter_position_low,k==1u),d,emitter.distance);
if k==0u { segment=delta; } else { scatter_segment=delta; }
}
let segment = ray_segment_precise(o,o_low,previous_position,previous_position_low,d,emitter.distance);
if medium_count>0u {
throughput *= exp(-media[medium_count-1u].sigma*length(segment));
}
let scatter_segment = ray_segment_precise(o,o_low,previous_scatter_position,previous_scatter_position_low,d,emitter.distance);
let pdf = area_pdf(light,scatter_segment);
if pdf>0.0 && !suppress_direct && !photon_owns_smooth_tail(smooth_tail,false) {
let weight = select(power_weight(previous_pdf,pdf),1.0,previous_delta);
@@ -1426,17 +1353,10 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
position=point.hi;position_low=point.lo;
}
let shading_position=position+position_low;
// The travelled ray segment and, inside a medium, its absorbing segment
// share one inlined precise subtraction.
var travelled=vec3<f32>(0.0);
var absorbing=vec3<f32>(0.0);
for (var k=0u; k<select(1u,2u,medium_count>0u); k+=1u) {
let delta=precise_point_delta(position,position_low,select(o,previous_position,k==1u),select(o_low,previous_position_low,k==1u));
if k==0u { travelled=delta; } else { absorbing=delta; }
}
texture_path_length+=length(travelled);
texture_path_length+=length(precise_point_delta(position,position_low,o,o_low));
if medium_count > 0u {
throughput *= exp(-media[medium_count-1u].sigma*length(absorbing));
let segment=precise_point_delta(position,position_low,previous_position,previous_position_low);
throughput *= exp(-media[medium_count-1u].sigma*length(segment));
}
previous_position = position; previous_position_low=position_low;
o = position + geometric*(u.lighting.x*2.0); o_low=vec3<f32>(0.0);
@@ -1857,7 +1777,7 @@ fn ordering_candidate(t: Triangle, o: vec3<f32>, d: vec3<f32>) -> vec2<f32> {
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
let weights=edge_weights(a,b,c);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
let det=weights.x+weights.y+weights.z;
let bary=weights/det;
let distance=surface_ray_depth(t,bary,o,kz)*shear.z;
+65 -166
View File
@@ -2361,71 +2361,48 @@ fn shader_validates_without_a_device() {
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&caustic).unwrap();
}
/// Visits every statement a native compiler inlines into `block`: nested blocks, and at each call
/// site the callee's body again, as inlining copies it.
fn each_inlined(module: &naga::Module, block: &naga::Block, visit: &mut impl FnMut(&naga::Statement)) {
for statement in block.iter() {
visit(statement);
match statement {
naga::Statement::Call { function, .. } => each_inlined(module, &module.functions[*function].body, visit),
naga::Statement::Block(inner) => each_inlined(module, inner, visit),
naga::Statement::If { accept, reject, .. } => {
each_inlined(module, accept, visit);
each_inlined(module, reject, visit);
}
naga::Statement::Switch { cases, .. } => cases.iter().for_each(|case| each_inlined(module, &case.body, visit)),
naga::Statement::Loop { body, continuing, .. } => {
each_inlined(module, body, visit);
each_inlined(module, continuing, visit);
}
_ => {}
}
}
}
/// #6705: native compilers inline every call. With 33 inlined BVH walks in `main` (three path
/// samples, a shadow walk per light source family) NVIDIA's Vulkan compiler crashed or ran out
/// of memory and never built the pipeline. Count the walks `main` inlines, so another call site
/// is a deliberate choice, checked on such a driver.
#[test]
fn main_inlines_few_bvh_walks() {
fn callees(block: &naga::Block, out: &mut Vec<naga::Handle<naga::Function>>) {
for statement in block.iter() {
match statement {
naga::Statement::Call { function, .. } => out.push(*function),
naga::Statement::Block(inner) => callees(inner, out),
naga::Statement::If { accept, reject, .. } => {
callees(accept, out);
callees(reject, out);
}
naga::Statement::Switch { cases, .. } => cases.iter().for_each(|case| callees(&case.body, out)),
naga::Statement::Loop { body, continuing, .. } => {
callees(body, out);
callees(continuing, out);
}
_ => {}
}
}
}
fn walks(module: &naga::Module, function: &naga::Function) -> usize {
let mut called = Vec::new();
callees(&function.body, &mut called);
called
.into_iter()
.map(|handle| {
let callee = &module.functions[handle];
let walk = matches!(callee.name.as_deref(), Some("trace_precise_within" | "trace_ordered_within" | "origin_boundary"));
usize::from(walk) + walks(module, callee)
})
.sum()
}
let module = naga::front::wgsl::parse_str(SHADER).unwrap();
let main = module.entry_points.iter().find(|entry| entry.name == "main").unwrap();
let mut inlined = 0;
each_inlined(&module, &main.function.body, &mut |statement| {
if let naga::Statement::Call { function, .. } = statement {
let callee = module.functions[*function].name.as_deref();
inlined += usize::from(matches!(callee, Some("trace_precise_within" | "trace_ordered_within" | "origin_boundary")));
}
});
let inlined = walks(&module, &main.function);
assert!(inlined <= 5, "`main` inlines {inlined} BVH walks");
}
/// #14105: the walks' size matters too. A native compiler's time and memory grow faster than the
/// statements it inlines: on one Vulkan driver the path programs took up to 17 minutes and 61 GB
/// to compile. Each budget is about 1.5 times its entry point's count when #14105 was fixed, far
/// below the count before (before -> fixed): path `main` 196,450 -> 28,414, `optical_resolve`
/// 54,888 -> 7,777, `denoise_guides` 70,517 -> 7,906, caustic `main` 211,786 -> 33,386 and
/// `photon_emit` 107,085 -> 14,782. A larger program is a deliberate choice, checked on such a driver.
#[test]
fn heavy_entry_points_inline_few_statements() {
let guides = format!("{SHADER}\n{}", denoise::GUIDES);
let caustics = photons::shader_source().unwrap();
for (program, source, entry, budget) in [
("path", SHADER, "main", 45_000),
("path", SHADER, "optical_resolve", 12_000),
("denoise", guides.as_str(), "denoise_guides", 12_000),
("caustic", caustics.as_str(), "main", 50_000),
("caustic", caustics.as_str(), "photon_emit", 22_000),
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
let function = &module.entry_points.iter().find(|point| point.name == entry).unwrap().function;
let mut statements = 0usize;
each_inlined(&module, &function.body, &mut |_| statements += 1);
assert!(statements <= budget, "the {program} program's `{entry}` inlines {statements} statements, over its budget of {budget}");
}
}
#[test]
fn geometry_materialization_lane_keeps_positive_zero_at_the_shader_offset() {
let Some(executor) = executor() else { return };
@@ -3975,27 +3952,23 @@ fn texture_admission_rejects_glass_and_stale_identity_and_honors_cancellation()
assert!(admission(&scene, &input, JobOptions::default()).is_err());
}
#[test]
fn host_readiness_includes_unmapped_glass() {
fn host_readiness_includes_unmapped_non_nested_glass() {
let Some(executor) = executor() else { return };
let context = &executor.context;
assert!(context.prepared_scene_pipeline(true, false, false).is_ok());
assert!(context.prepared_scene_pipeline(false, false, false).is_ok(), "a ready host must already prepare ordinary unmapped glass");
assert!(context.prepared_scene_pipeline(true, false, false, false).is_ok());
assert!(context.prepared_scene_pipeline(false, false, false, false).is_ok(), "a ready host must already prepare ordinary unmapped glass");
assert!(context.clamped_pipelines.iter().all(|slot| slot.get().is_none()), "indirect-clamp programs stay lazy");
let plain = scene_pipeline_variant(false, false).unwrap();
let plain = scene_pipeline_variant(false, false, false).unwrap();
assert!(context.scene_pipelines[plain].get().is_some());
for (opaque, maps) in [(true, true), (false, true)] {
let variant = scene_pipeline_variant(opaque, maps).unwrap();
assert!(context.scene_pipelines[variant].get().is_none(), "the mapped program stays lazy");
for (opaque, maps, nested) in [(true, true, false), (false, false, true), (false, true, false), (false, true, true)] {
let variant = scene_pipeline_variant(opaque, maps, nested).unwrap();
assert!(context.scene_pipelines[variant].get().is_none(), "uncommon map/nesting programs stay lazy");
}
let mut glass = sphere(white());
glass.meshes[0].transmission = Some(clear_glass());
let options = JobOptions { samples: 1, max_depth: 3, ..Default::default() };
// Nested media are a uniform: such a job runs the ready unmapped program, no second compile.
let nested = Task::new(context.clone(), glass.clone(), request(2), JobOptions { nested_media: true, ..options }, CancelToken::default()).unwrap();
assert_eq!(&nested.render_pipeline, context.prepared_scene_pipeline(false, false, false).unwrap());
let mut task = Task::new(context.clone(), glass, request(2), options, CancelToken::default()).unwrap();
assert!(!task.opaque_scene && !task.has_textures);
assert_eq!(task.render_pipeline, nested.render_pipeline);
task.complete_sample().unwrap();
let pixels = task.read_linear().unwrap();
assert_eq!(pixels.len(), 4);
@@ -4004,7 +3977,7 @@ fn host_readiness_includes_unmapped_glass() {
let options = JobOptions { clamp_indirect: Some(2.0), ..options };
let mut clamped = Task::new(context.clone(), sphere(white()), request(2), options, CancelToken::default()).unwrap();
clamped.complete_sample().unwrap();
let opaque = clamped_pipeline_variant(true, false);
let opaque = clamped_pipeline_variant(true, false, false);
for (index, slot) in context.clamped_pipelines.iter().enumerate() {
assert_eq!(slot.get().is_some(), index == opaque, "clamped program {index}");
}
@@ -4057,22 +4030,33 @@ fn opaque_scene_specialization_preserves_body_face_and_volume_media() {
let mut variants = std::collections::BTreeSet::new();
for opaque in [false, true] {
for maps in [false, true] {
let slot = scene_pipeline_variant(opaque, maps);
assert_eq!(slot.is_none(), opaque && !maps, "only opaque/no-map uses the eager base");
assert!(slot.is_none_or(|index| index < SCENE_PIPELINE_CACHE_SLOTS));
variants.insert(slot);
for nested in [false, true] {
let slot = scene_pipeline_variant(opaque, maps, nested);
assert_eq!(slot.is_none(), opaque && !maps, "only opaque/no-map uses the eager base");
assert!(slot.is_none_or(|index| index < SCENE_PIPELINE_CACHE_SLOTS));
variants.insert(slot);
}
if opaque {
assert_eq!(scene_pipeline_variant(opaque, maps, false), scene_pipeline_variant(opaque, maps, true));
} else {
assert_ne!(
scene_pipeline_variant(opaque, maps, false),
scene_pipeline_variant(opaque, maps, true),
"disjoint and nested options retain separate scene cache entries"
);
}
}
}
assert_eq!(variants.len(), SCENE_PIPELINE_CACHE_SLOTS + 1);
// The slot is the program: an opaque scene with maps runs the full program with maps.
assert_eq!(scene_pipeline_variant(true, true), scene_pipeline_variant(false, true), "mapped opaque and glass compile one program");
// Each of the three programs has its own clamped slot.
// Each of the six programs has its own clamped slot.
let mut clamped = std::collections::BTreeMap::new();
for opaque in [false, true] {
for maps in [false, true] {
let slot = clamped_pipeline_variant(opaque, maps);
assert!(slot < CLAMPED_PIPELINE_CACHE_SLOTS);
clamped.entry(slot).or_insert_with(std::collections::BTreeSet::new).insert(scene_pipeline_variant(opaque, maps));
for nested in [false, true] {
let slot = clamped_pipeline_variant(opaque, maps, nested);
assert!(slot < CLAMPED_PIPELINE_CACHE_SLOTS);
clamped.entry(slot).or_insert_with(std::collections::BTreeSet::new).insert(scene_pipeline_variant(opaque, maps, nested));
}
}
}
assert_eq!(clamped.len(), CLAMPED_PIPELINE_CACHE_SLOTS);
@@ -7515,99 +7499,14 @@ fn gpu_punctual_offset_preserves_small_shift_after_large_local_barycentric_displ
#[test]
fn shared_bvh_node_walk_preserves_original_ordinary_and_compensated_hit_bits() {
// Frozen f53 policy, not re-derived from the new implementation: the old
// precise and ordered walkers under test-local names, and the old
// triangle_ahead_precise the shader no longer needs. legacy_precise_policy
// sends zero low parts to legacy_ordered_within, as the old policy did; both
// keep three direct difference_products calls per candidate, which the shader
// now makes at one edge_weights call site. This reference entry inlines three
// Frozen f53 policy, not re-derived from the new implementation. Only its
// test-local function name changes; it still calls the byte-identical old
// ordered walker for zero low parts. This reference entry inlines three
// real walks, rather than compiling the historical seven-walk main.
let legacy = r#"fn triangle_ahead_precise(t: Triangle, o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>) -> bool {
if all(o_low==vec3<f32>(0.0)) { return triangle_ahead(t,o,d); }
let lo=precise_point_delta(min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz)),vec3<f32>(0.0),o,o_low);
let hi=precise_point_delta(max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz)),vec3<f32>(0.0),o,o_low);
if any((lo>=vec3<f32>(0.0))&(d<vec3<f32>(0.0))) || any((hi<=vec3<f32>(0.0))&(d>vec3<f32>(0.0))) { return false; }
if any((d==vec3<f32>(0.0))&((lo>vec3<f32>(0.0))|(hi<vec3<f32>(0.0)))) { return false; }
return true;
}
fn legacy_ordered_within(o: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool, limit: f32, blocker: f32) -> Hit {
var hit = Hit(limit, 0xffffffffu, vec3<f32>(0.0));
let origin_error=max(max(abs(o.x),max(abs(o.y),abs(o.z))),1.0)*(1.0/1048576.0);
var source: Triangle;
if excluded!=0xffffffffu { source=triangle(excluded); }
// Project every candidate into the same ray-aligned 2D coordinate system.
// Shared edges then use identical products with opposite signs, avoiding the
// independently rounded barycentric comparisons of Moller-Trumbore.
var kz = 0u;
if abs(d.y)>abs(d.x) { kz=1u; }
if abs(d.z)>abs(d[kz]) { kz=2u; }
let kx=(kz+1u)%3u; let ky=(kx+1u)%3u;
let shear=vec3<f32>(d[kx]/d[kz],d[ky]/d[kz],1.0/d[kz]);
let order=select(-1.0,1.0,d[kz]>0.0);
var best=FAR;
// Children are tested at their parent and visited near-first. The build
// bounds tree depth to 30, so at most 31 nodes are pending.
var stack: array<u32, 32>;
var entry: array<f32, 32>;
if nodes[0].first == 0xffffffffu { return hit; }
entry[0] = box_entry_padded(nodes[0], o, d, hit.distance, u.lighting.x);
if entry[0] < 0.0 { return hit; }
stack[0] = 0u;
var size = 1u;
loop {
if size == 0u { break; }
size -= 1u;
if entry[size] > hit.distance { continue; }
let node = nodes[stack[size]];
if node.count == 0u {
if size + 2u > 32u { break; }
let left = box_entry_padded(nodes[node.first], o, d, hit.distance, u.lighting.x);
let right = box_entry_padded(nodes[node.first+1u], o, d, hit.distance, u.lighting.x);
var near_child = node.first; var near_entry = left;
var far_child = node.first+1u; var far_entry = right;
if right >= 0.0 && (left < 0.0 || right < left) {
near_child = node.first+1u; near_entry = right;
far_child = node.first; far_entry = left;
}
if far_entry >= 0.0 { stack[size] = far_child; entry[size] = far_entry; size += 1u; }
if near_entry >= 0.0 { stack[size] = near_child; entry[size] = near_entry; size += 1u; }
continue;
}
for (var i = node.first; i < node.first + node.count; i += 1u) {
if i == excluded { continue; }
// A straight continuation cannot revisit its producing exact plane.
// IDs join only connected, exactly coplanar uploaded f32 triangles
// of one body; curved facets and other bodies remain candidates.
if excluded!=0xffffffffu && geometry[geometry[3]+i]==geometry[geometry[3]+excluded] { continue; }
let t = triangle(i);
if !triangle_ahead(t,o,d) { continue; }
let pa=t.p0.xyz-o; let pb=t.p1.xyz-o; let pc=t.p2.xyz-o;
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
let det=weights.x+weights.y+weights.z;
if det==0.0 { continue; }
// Retain all three directly evaluated weights: 1-u-v can erase a
// small corner clearance when the other two weights sum to one.
let bary=weights/det;
let depth=surface_ray_depth(t,bary,o,kz);
let distance=depth*shear.z;
let key=depth*order;
if excluded!=0xffffffffu && repeated_crossing(t,source,d,inside,distance,origin_error) { continue; }
if distance > minimum && distance < limit && (key < best || (key == best && tie_preferred(t,d,inside))) {
hit = Hit(distance,i,bary); best=key;
if any_hit || (distance < blocker && (t.base.w<=0.0 || t.coat.w<=0.0)) { return hit; }
}
}
}
return hit;
}
fn legacy_precise_policy(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool, limit: f32, blocker: f32) -> Hit {
let legacy = r#"fn legacy_precise_policy(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool, limit: f32, blocker: f32) -> Hit {
// Dispatch once before traversal. Ordinary rays keep the original lean BVH
// loop, without precision-helper branches inside every candidate/node test.
if all(o_low==vec3<f32>(0.0)) { return legacy_ordered_within(o,d,any_hit,excluded,minimum,inside,limit,blocker); }
if all(o_low==vec3<f32>(0.0)) { return trace_ordered_within(o,d,any_hit,excluded,minimum,inside,limit,blocker); }
var hit = Hit(limit, 0xffffffffu, vec3<f32>(0.0));
// Project every candidate into the same ray-aligned 2D coordinate system.
// Shared edges then use identical products with opposite signs, avoiding the
+2 -7
View File
@@ -20,13 +20,8 @@ fn volume_segment(o: vec3<f32>, d: vec3<f32>, limit: f32) -> vec2<f32> {
fn volume_transmittance_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, distance: f32) -> f32 {
if all(o_low==vec3<f32>(0.0)) { return volume_transmittance(o,d,distance); }
if u.volume_min.w<=0.0 { return 1.0; }
// Both box bounds share one inlined precise subtraction.
var lo=vec3<f32>(0.0);
var hi=vec3<f32>(0.0);
for (var k=0u; k<2u; k+=1u) {
let delta=precise_point_delta(select(u.volume_min.xyz,u.volume_max.xyz,k==1u),vec3<f32>(0.0),o,o_low);
if k==0u { lo=delta; } else { hi=delta; }
}
let lo=precise_point_delta(u.volume_min.xyz,vec3<f32>(0.0),o,o_low);
let hi=precise_point_delta(u.volume_max.xyz,vec3<f32>(0.0),o,o_low);
var enter=0.0; var leave=distance;
for(var axis=0u;axis<3u;axis+=1u) {
if d[axis]==0.0 { if lo[axis]>0.0 || hi[axis]<0.0 { return 1.0; } }
-1
View File
@@ -18,7 +18,6 @@ solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-doc = { workspace = true }
solidcraft-format = { workspace = true }
solidcraft-profile = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+1 -1
View File
@@ -13,7 +13,7 @@ use std::process::Command;
/// The workspace crates a design's model is computed from: `doc` and every workspace crate it
/// depends on (a test checks the list against their manifests).
const EVALUATION_CRATES: [&str; 11] = ["color", "geom", "profile", "smlib", "sketch", "kernel", "render", "derive", "doc", "format", "viewport"];
const EVALUATION_CRATES: [&str; 10] = ["color", "geom", "profile", "smlib", "sketch", "kernel", "render", "derive", "doc", "viewport"];
/// The other files the saved model depends on, relative to the workspace root.
const EVALUATION_FILES: [&str; 3] = ["crates/io/build.rs", "crates/io/src/design/native_cache.rs", ".cargo/config.toml"];
/// The committed trees whose every file computes models, hashed as one tree id each: the vendored
+180 -69
View File
@@ -1,12 +1,8 @@
//! Versioned design packages. Entries are never extracted to filesystem paths. The optional
//! model cache is only read back by the build that wrote it, for the exact document it was
//! written with.
#[cfg(test)]
use std::collections::BTreeMap;
use std::collections::BTreeSet;
#[cfg(test)]
use std::io::Write;
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod drawing;
@@ -17,8 +13,8 @@ pub use native_cache::read_design_geometry;
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use solidcraft_doc::{Document, FeatureKind, MeshData};
use solidcraft_format::container::{Limits, Reader, Writer, digest};
use crate::{IoError, Result, design_metadata::replace};
@@ -64,58 +60,35 @@ pub struct DesignPreview {
}
/// Fast favors interactive file operations; Compact trades CPU time for space.
pub use solidcraft_format::container::Compression as DesignCompression;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DesignCompression {
#[default]
Fast,
Compact,
}
fn invalid(message: impl std::fmt::Display) -> IoError {
IoError::Invalid(format!("design container: {message}"))
}
/// The shared container's refusals are a design container's.
impl From<solidcraft_format::FormatError> for IoError {
fn from(e: solidcraft_format::FormatError) -> Self {
invalid(e)
}
}
/// Detect the package signature independently of the file name.
pub fn is_design_container(bytes: &[u8]) -> bool {
solidcraft_format::container::is_container(bytes)
bytes.starts_with(b"PK\x03\x04")
}
fn fields(pairs: impl IntoIterator<Item = (&'static str, Value)>) -> Map<String, Value> {
pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect()
}
/// A design container's budgets ([`crate::limits`]).
const LIMITS: Limits = Limits {
max_entries: MAX_ENTRIES,
max_expanded,
part_limit,
level: part_level,
check_size: |length| crate::limits::check(crate::limits::Kind::Design, length),
};
/// The Deflate level of a part: STEP sources take [`STEP_LEVEL`], and so does a mesh past the file
/// limit: level 1 of a large payload leaves more on disk, and that part exists only because
/// compression is what makes it fit. Everything else is level 1.
fn part_level(name: &str, length: usize) -> i64 {
if name.ends_with(".step") || (name.ends_with(".mesh") && length > max_part()) { STEP_LEVEL } else { 1 }
fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// A design container being written, within [`LIMITS`].
struct PackageWriter(Writer);
impl std::ops::Deref for PackageWriter {
type Target = Writer;
fn deref(&self) -> &Writer {
&self.0
}
}
impl std::ops::DerefMut for PackageWriter {
fn deref_mut(&mut self) -> &mut Writer {
&mut self.0
}
struct PackageWriter {
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
names: BTreeSet<String>,
expanded: usize,
resolved: usize,
}
/// The uncompressed size a part may have. STEP and mesh blobs may be larger than the
@@ -132,10 +105,55 @@ fn part_limit(name: &str) -> usize {
impl PackageWriter {
fn new() -> PackageWriter {
PackageWriter(Writer::new(LIMITS))
PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 }
}
fn finish(self) -> Result<Vec<u8>> {
Ok(self.0.finish()?)
Ok(self.zip.finish().map_err(invalid)?.into_inner())
}
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
if bytes.len() > part_limit(name) {
return Err(invalid("part too large"));
}
if !self.names.insert(name.to_owned()) {
return Ok(());
}
self.expanded = self.expanded.checked_add(bytes.len()).ok_or_else(|| invalid("expanded size overflow"))?;
if self.expanded > max_expanded() || self.names.len() > MAX_ENTRIES {
return Err(invalid("package too large"));
}
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
// A mesh past the file limit uses the STEP level: level 1 of a large payload leaves
// more on disk, and this part exists only because compression is what makes it fit.
let level = if name.ends_with(".step") || (name.ends_with(".mesh") && bytes.len() > max_part()) { STEP_LEVEL } else { 1 };
// A part of 4 GiB or more (only with a raised file limit, #4086) needs a ZIP64 entry.
let options = zip::write::SimpleFileOptions::default()
.compression_method(method)
.compression_level(compressed.then_some(level))
.large_file(bytes.len() as u64 >= u64::from(u32::MAX));
self.zip.start_file(name, options).map_err(invalid)?;
self.zip.write_all(bytes).map_err(invalid)
}
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep
/// the file within `budget` and its expanded size within the reader's budget?
fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
self.names.len().saturating_add(entries) <= MAX_ENTRIES && extra <= self.space(entries, budget)
}
/// The bytes `entries` more optional parts may hold together ([`PackageWriter::room`]).
fn space(&self, entries: usize, budget: usize) -> usize {
if self.names.len().saturating_add(entries) > MAX_ENTRIES {
return 0;
}
// The bytes written so far, plus what the open compressed part may still hold back.
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
budget.saturating_sub(written.saturating_add(directory)).min(max_expanded().saturating_sub(self.expanded))
}
fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value> {
self.resolved =
self.resolved.checked_add(bytes.len()).filter(|&n| n <= max_expanded()).ok_or_else(|| invalid("resolved payloads too large"))?;
let name = format!("blobs/{}.{extension}", digest(bytes));
self.part(&name, bytes, compressed)?;
Ok(Value::String(name))
}
}
@@ -480,25 +498,120 @@ fn decode_cloud(bytes: &[u8], cloud: &mut solidcraft_doc::point_cloud::PointClou
cloud.check().map_err(invalid)
}
/// A design container being read, within [`LIMITS`].
struct PackageReader<R: Read + Seek>(Reader<R>);
impl<R: Read + Seek> std::ops::Deref for PackageReader<R> {
type Target = Reader<R>;
fn deref(&self) -> &Reader<R> {
&self.0
}
}
impl<R: Read + Seek> std::ops::DerefMut for PackageReader<R> {
fn deref_mut(&mut self) -> &mut Reader<R> {
&mut self.0
}
struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
document: Option<Arc<[u8]>>,
}
impl<R: Read + Seek> PackageReader<R> {
fn new(source: R) -> Result<Self> {
Ok(Self(Reader::new(source, LIMITS)?))
fn new(mut source: R) -> Result<Self> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
crate::limits::check(crate::limits::Kind::Design, length).map_err(invalid)?;
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. Our
// bounded format does not need ZIP64/multiple disks: verify the declared
// entry count as well so duplicate names cannot silently select a payload.
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16> {
let array: [u8; 2] = end.get(offset..offset + 2).ok_or_else(|| invalid("truncated ZIP directory"))?.try_into().map_err(invalid)?;
Ok(u16::from_le_bytes(array))
};
let declared = u16_at(10)? as usize;
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
return Err(invalid("too many entries or unsupported ZIP directory"));
}
// A design file is a ZIP with nothing in front of it. Saying so skips zip's search for a
// prefix, which reads back through the stored parts and would load a large embedded
// source before the preview can be shown.
let config = zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) };
let mut zip = zip::ZipArchive::with_config(config, source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
|| u16_at(10)? as usize != zip.len()
|| end.len() != 22 + u16_at(20)? as usize
{
return Err(invalid("duplicate entries or unsupported ZIP directory"));
}
let mut names = BTreeSet::new();
let mut total = 0u64;
for i in 0..zip.len() {
let part = zip.by_index_raw(i).map_err(invalid)?;
let name = part.name().map_err(invalid)?.into_owned();
if name.is_empty()
|| name.contains('\\')
|| name.split('/').any(|c| c.is_empty() || c == "." || c == "..")
|| !names.insert(name.to_owned())
{
return Err(invalid("duplicate or invalid archive entry"));
}
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
let limit = part_limit(&name);
if part.size() > limit as u64 || total > max_expanded() as u64 {
return Err(invalid("expanded package too large"));
}
}
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
}
// Bind provisional caches and authoritative reconstruction to identical metadata,
// even if another process overwrites the open file in place. Blobs remain hash-checked.
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
fn has_part(&self, name: &str) -> bool {
self.zip.index_for_name(name).is_some()
}
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
let expected = part.size();
if expected > limit as u64 {
return Err(invalid("part too large"));
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(expected as usize).map_err(|_| invalid("could not reserve required part"))?;
// A compressed part may expand beyond its declared size (a decompression bomb): stop
// one byte past it instead of at the limit.
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
Ok(bytes)
}
fn blob(&mut self, value: &Value, field: &str, extension: &str) -> Result<&[u8]> {
self.limited_blob(value, field, extension, part_limit(&format!("blob.{extension}")))
}
fn limited_blob(&mut self, value: &Value, field: &str, extension: &str, limit: usize) -> Result<&[u8]> {
let name = value.get(field).and_then(Value::as_str).ok_or_else(|| invalid(format!("missing `{field}` reference")))?;
let hash = name
.strip_prefix("blobs/")
.and_then(|n| n.strip_suffix(&format!(".{extension}")))
.filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
.ok_or_else(|| invalid("invalid blob reference"))?;
if !self.blobs.contains_key(name) {
let bytes = self.limited_part(name, limit)?;
if digest(&bytes) != hash {
return Err(invalid(format!("blob hash mismatch: {name}")));
}
self.blobs.insert(name.to_owned(), bytes);
}
let bytes = self.blobs.get(name).ok_or_else(|| invalid("missing blob"))?;
if bytes.len() > limit {
return Err(invalid("blob exceeds its resource budget"));
}
// Count repeated references too: a tiny archive must not expand into unbounded
// owned document strings/arrays when the same blob is referenced repeatedly.
self.hydrated =
self.hydrated.checked_add(bytes.len()).filter(|&n| n <= max_expanded()).ok_or_else(|| invalid("resolved payloads too large"))?;
Ok(bytes)
}
}
@@ -732,7 +845,7 @@ fn hydrate_newer<R: Read + Seek>(doc: &mut Document, metadata: &Value, container
for (index, feature) in doc.features.iter_mut().enumerate() {
let Some(value) = metadata.get("features").and_then(|v| v.get(index)) else { continue };
let readable = match &mut feature.kind {
FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()).map_err(IoError::from),
FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()),
FeatureKind::MeshImport { meshes, .. } => {
let mut check = meshes.clone();
check.iter_mut().enumerate().try_for_each(|(i, mesh)| {
@@ -772,7 +885,8 @@ fn hydrate_newer<R: Read + Seek>(doc: &mut Document, metadata: &Value, container
file.container = Some(container);
}
// A blob a typed record uses comes back from that record; others only for what is kept.
for name in reader.part_names() {
let names: Vec<String> = reader.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect();
for name in names {
let ours = matches!(name.as_str(), "document.json" | "container.json") || name.starts_with("cache/");
let unused_blob = name.starts_with("blobs/") && !file.mentions(&name) && !foreign_mentions(&doc.features, &name);
if !ours && !unused_blob {
@@ -864,11 +978,8 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
if newer_container && doc.newer_file.is_none() {
let current = solidcraft_doc::format::FORMAT;
let says = container.get("format").and_then(Value::as_str).unwrap_or("a later container").to_string();
let file = solidcraft_doc::newer_file::newer_file(&solidcraft_doc::format::Newer {
format: current,
reads_up_to: current,
unknown_changes: vec![says],
});
let mut file = solidcraft_doc::newer_file::NewerFile::default();
(file.format, file.reads_up_to, file.unknown_changes) = (current, current, vec![says]);
doc.newer_file = Some(Arc::new(file));
}
if doc.newer_file.is_some() {
-1
View File
@@ -4,7 +4,6 @@
//! cache to the exact document, and to the source, platform and geometry kernel that computed it
//! (docs/design-container.md).
use super::*;
use sha2::{Digest, Sha256};
use solidcraft_doc::{ModelState, SavedModel};
const FORMAT: &str = "solidcraft-model-cache/10";
+30 -6
View File
@@ -4,20 +4,44 @@ pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
const MAX_BYTES: usize = 256 << 10;
const FORMAT: &str = "solidcraft-thumbnail/1";
/// The design's picture, as the shared container keeps it.
const PICTURE: solidcraft_format::container::Thumbnail =
solidcraft_format::container::Thumbnail { format: FORMAT, app: env!("CARGO_PKG_VERSION"), size: THUMBNAIL_SIZE, max_bytes: MAX_BYTES };
fn valid_header(bytes: &[u8]) -> bool {
bytes.len() <= MAX_BYTES
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
}
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
Ok(PICTURE.write(writer, png, document, max_design())?)
if !valid_header(png) {
return Ok(());
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if !writer.room(2, key.len().saturating_add(png.len()), max_design()) {
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("thumbnail container version"));
}
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(16 << 20)?;
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
// Consumers must also use a bounded PNG decoder; the header is not a decode.
Ok(PICTURE.read(reader, 16 << 20)?)
Ok(png)
}
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
/// decode with dimension/allocation limits; invalid pictures are cache misses.
-44
View File
@@ -1,44 +0,0 @@
//! Issue #12126: a sweep whose profile reaches past the centre of a bend in its path. SMLib
//! refuses it (the arc's piece is a turn about an axis the profile crosses), and the message
//! said only "SMLib failed (status 1025)".
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::FRAC_PI_2;
use solidcore_record::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::{PathSeg, sweep};
/// A square of side `side` on the plane across the path's start (the YZ plane at the origin).
fn square(side: f64) -> Region2 {
let h = side / 2.0;
Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: vec![] }
}
fn across_start() -> Plane {
Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z }
}
/// A straight run along X, then a quarter turn of `radius` towards +Y.
fn line_then_bend(radius: f64) -> [PathSeg; 2] {
[
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(20.0, 0.0, 0.0) },
PathSeg::Arc { a: Vec3::new(20.0, 0.0, 0.0), center: Vec3::new(20.0, radius, 0.0), axis: Vec3::Z, angle: FRAC_PI_2 },
]
}
#[test]
fn a_profile_past_the_centre_of_a_bend_names_the_bend() {
// The square reaches 2 mm towards the bend's centre, which is 1.5 mm away.
let e = sweep(&across_start(), &square(4.0), &line_then_bend(1.5)).expect_err("the profile crosses the bend's axis");
let msg = e.to_string();
assert!(!msg.contains("status 1025"), "{msg}");
assert!(msg.contains("bend") && msg.contains("1.5"), "{msg}");
}
#[test]
fn a_profile_inside_the_bend_sweeps() {
// The same square round a 3 mm bend stays clear of its centre.
sweep(&across_start(), &square(4.0), &line_then_bend(3.0)).unwrap();
}
-2
View File
@@ -59,8 +59,6 @@ mod issue_1038_tests;
#[cfg(test)]
mod issue_1080_tests;
#[cfg(test)]
mod issue_12126_tests;
#[cfg(test)]
mod issue_1510_tests;
#[cfg(test)]
mod issue_2005_tests;
-19
View File
@@ -188,25 +188,6 @@ fn rim_loops(asm: &Assembly, fi: usize, n: Vec3) -> Result<(Vec<AUse>, Vec<AUse>
}
fn profile(pieces: &[Piece], plane: &Plane) -> Result<Loop2> {
let mut pieces = pieces.to_vec();
// A source edge's vertex may be within its own tolerance of the circle rather than on it.
// An extruded tool has fresh, tighter tolerances, so join the following piece to the
// circle's evaluated end instead of carrying that source vertex into the tool profile.
for i in 0..pieces.len() {
let j = (i + 1) % pieces.len();
let Some(piece) = pieces.get(i).copied() else { return Err(fail("piece")) };
if piece.arc.is_some() {
let end = piece.at(plane.normal(), 1.0);
if let Some(current) = pieces.get_mut(i) {
current.b = end;
}
if i != j
&& let Some(next) = pieces.get_mut(j)
{
next.a = end;
}
}
}
let segs = pieces
.iter()
.map(|p| {
+30 -241
View File
@@ -10,14 +10,10 @@
//! through the sections and the stations together.
//!
//! Sections are exact NURBS curves ([`Section`], from the sketch segments' record curves); a
//! station is the plain loft's control points moved by an affine map, so each is exact too. Before
//! the stations are made, every section's k-th curve is brought to one form ([`unified`]: the same
//! degree and knots, the curve itself unchanged) so that the control points of a line pair with
//! those of an arc or a spline against it (#12512); a station's control points then go straight
//! between the sections' and its weights with them, so the weights need not agree either.
//! station is the plain loft's control points moved by an affine map, so each is exact too.
use solidcore_record::{Plane, Seg2, Vec3};
use solidcraft_geom::bspline::{CurveN, HPoint};
use solidcraft_geom::bspline::CurveN;
use solidcraft_geom::prelude::*;
use crate::sweep_path::RailFit;
@@ -82,136 +78,15 @@ fn point_at(c: &CurveN<4>, f: f64) -> Option<Vec3> {
(w.abs() > 1e-300).then(|| Vec3::new(x / w, y / w, z / w))
}
/// Whether two knots are the same (curves run on [0, 1]).
fn near(x: f64, y: f64) -> bool {
(x - y).abs() <= 1e-9
}
fn hpoint(h: [f64; 4]) -> HPoint {
HPoint { x: h[0], y: h[1], z: h[2], w: h[3] }
}
fn array(h: HPoint) -> [f64; 4] {
[h.x, h.y, h.z, h.w]
}
/// `c` on t in [0, 1] (its knots mapped affinely; the curve is unchanged).
fn normalized(c: &CurveN<4>) -> Result<CurveN<4>> {
let (p, n) = (c.degree, c.ctrl.len());
let bad = || KernelError::Invalid("a loft section curve".into());
let (Some(&t0), Some(&t1)) = (c.knots.get(p), c.knots.get(n)) else { return Err(bad()) };
if n <= p || c.knots.len() != n + p + 1 || !(t1 > t0) || !t0.is_finite() || !t1.is_finite() {
return Err(bad());
}
let knots = c.knots.iter().map(|u| ((u - t0) / (t1 - t0)).clamp(0.0, 1.0)).collect();
Ok(CurveN { degree: p, knots, ctrl: c.ctrl.clone() })
}
/// A Bézier piece's homogeneous control points one degree higher, the same curve (NURBS Book
/// eq. 5.36).
fn elevated(pts: &[HPoint]) -> Vec<HPoint> {
let p = pts.len().saturating_sub(1);
let at = |i: usize| pts.get(i).copied().unwrap_or(HPoint::ZERO);
(0..=p + 1)
.map(|i| {
let a = i as f64 / (p + 1) as f64;
let prev = if i == 0 { HPoint::ZERO } else { at(i - 1) * a };
prev + at(i) * (1.0 - a)
})
.collect()
}
/// The distinct interior knots of `c` (on [0, 1]) with their multiplicities, in order.
fn interior(c: &CurveN<4>) -> Vec<(f64, usize)> {
let mut out: Vec<(f64, usize)> = Vec::new();
for &u in c.knots.iter().filter(|u| **u > 0.0 && **u < 1.0) {
match out.last_mut() {
Some(last) if near(last.0, u) => last.1 += 1,
_ => out.push((u, 1)),
}
}
out
}
/// `c` (on [0, 1]) as a curve of `degree`, the same curve: split into its Bézier pieces, each
/// raised (exactly) and joined again, at each break a knot of multiplicity `degree`.
fn raised(c: &CurveN<4>, degree: usize) -> Result<CurveN<4>> {
if c.degree == degree {
return Ok(c.clone());
}
let fail = |m: &str| KernelError::Failed(format!("loft rails: {m}"));
if c.degree > degree {
return Err(fail("degree"));
}
let h: Vec<HPoint> = c.ctrl.iter().map(|x| hpoint(*x)).collect();
let mut breaks = vec![0.0];
breaks.extend(interior(c).iter().map(|(u, _)| *u));
breaks.push(1.0);
let mut ctrl: Vec<[f64; 4]> = Vec::new();
let mut knots = vec![0.0; degree + 1];
for (i, w) in breaks.windows(2).enumerate() {
let (_, mut piece) = solidcraft_geom::bspline::segment(c.degree, &c.knots, &h, w[0], w[1]).map_err(|e| fail(&e.to_string()))?;
while piece.len() < degree + 1 {
piece = elevated(&piece);
}
ctrl.extend(piece.iter().skip(usize::from(i > 0)).map(|q| array(*q)));
if i > 0 {
knots.extend(std::iter::repeat_n(w[0], degree));
}
}
knots.extend(std::iter::repeat_n(1.0, degree + 1));
if knots.len() != ctrl.len() + degree + 1 {
return Err(fail("degree"));
}
Ok(CurveN { degree, knots, ctrl })
}
/// Every section's k-th curve brought to one form without changing any curve: each on t in
/// [0, 1], raised to the highest degree in its column, then given every knot the others have
/// (Boehm insertion), so that their control points correspond one to one. The weights stay each
/// curve's own (a line's are all 1, an arc's are not); a station between takes its control
/// points straight between the sections' and its weights between theirs.
fn unified(sections: &[Section]) -> Result<Vec<Section>> {
let n = sections.first().map_or(0, |s| s.curves.len());
let fail = |m: &str| KernelError::Failed(format!("loft rails: {m}"));
let mut columns: Vec<Vec<CurveN<4>>> = Vec::with_capacity(n);
for k in 0..n {
let column: Vec<&CurveN<4>> = sections.iter().map(|s| s.curves.get(k).ok_or_else(|| fail("sections"))).collect::<Result<_>>()?;
let degree = column.iter().map(|c| c.degree).max().unwrap_or(1);
let mut curves: Vec<CurveN<4>> = column.iter().map(|c| raised(&normalized(c)?, degree)).collect::<Result<_>>()?;
// The union of the interior knots, each with its greatest multiplicity.
let mut union: Vec<(f64, usize)> = Vec::new();
for (u, m) in curves.iter().flat_map(interior) {
match union.iter_mut().find(|(x, _)| near(*x, u)) {
Some(e) => e.1 = e.1.max(m),
None => union.push((u, m)),
}
}
union.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut knots = vec![0.0; degree + 1];
knots.extend(union.iter().flat_map(|(u, m)| std::iter::repeat_n(*u, *m)));
knots.extend(std::iter::repeat_n(1.0, degree + 1));
for c in &mut curves {
let mut h: Vec<HPoint> = c.ctrl.iter().map(|x| hpoint(*x)).collect();
let mut k = c.knots.clone();
for (u, m) in &union {
let have = k.iter().filter(|x| near(**x, *u)).count();
for _ in have..*m {
(k, h) = solidcraft_geom::bspline::insert_knot(degree, &k, &h, *u).map_err(|e| fail(&e.to_string()))?;
}
}
if k.len() != knots.len() {
return Err(fail("knots"));
}
*c = CurveN { degree, knots: knots.clone(), ctrl: h.iter().map(|q| array(*q)).collect() };
}
columns.push(curves);
}
Ok(sections
.iter()
.enumerate()
.map(|(i, s)| Section { plane: s.plane, curves: columns.iter().filter_map(|col| col.get(i).cloned()).collect(), numbers: s.numbers.clone() })
.collect())
/// Whether two curves pair up: the same degree, knots and weights (their control points
/// correspond).
fn pairs(a: &CurveN<4>, b: &CurveN<4>) -> bool {
let near = |x: f64, y: f64| (x - y).abs() <= 1e-9 * (1.0 + x.abs());
a.degree == b.degree
&& a.ctrl.len() == b.ctrl.len()
&& a.knots.len() == b.knots.len()
&& a.knots.iter().zip(&b.knots).all(|(x, y)| near(*x, *y))
&& a.ctrl.iter().zip(&b.ctrl).all(|(x, y)| near(x[3], y[3]))
}
/// Where a rail meets a section: the point of the section it guides (as a wire parameter: the
@@ -247,8 +122,8 @@ impl Affine {
/// The loft's sections with stations added between them so that the loft follows `rails`
/// (polylines; see the module notes), in loft order: each entry one section's (or station's)
/// curves. `closed` says whether the sections close. Without rails the sections' own curves come
/// back. With rails, the sections' k-th curves are first brought to one form ([`unified`]) so
/// that their control points correspond.
/// back. With rails, every section's k-th curve has to pair with the others' (the same kind,
/// so their control points correspond).
pub(crate) fn railed(sections: &[Section], closed: bool, rails: &[Vec<Vec3>], fit: RailFit) -> Result<Vec<Vec<CurveN<4>>>> {
if rails.is_empty() {
return Ok(sections.iter().map(|s| s.curves.clone()).collect());
@@ -260,25 +135,11 @@ pub(crate) fn railed(sections: &[Section], closed: bool, rails: &[Vec<Vec3>], fi
let count = sections.len();
let lead = sections.first().map(|s| &s.curves).ok_or_else(|| fail("sections"))?;
let n = lead.len();
if count < 2 || n == 0 || sections.iter().any(|s| s.curves.len() != n) {
return Err(KernelError::Invalid("the loft sections have different numbers of curves".into()));
if count < 2 || n == 0 || sections.iter().any(|s| s.curves.len() != n || s.curves.iter().zip(lead).any(|(a, b)| !pairs(a, b))) {
return Err(KernelError::Invalid(
"not supported yet: a loft along guide rails between sections whose curves differ in kind (a line against an arc)".into(),
));
}
let sections = &unified(sections)?;
let lead = sections.first().map(|s| &s.curves).ok_or_else(|| fail("sections"))?;
// Each section's control points in space with their weights (`pctrl`: x, y, z, w) and alone
// (`ctrl`).
let pctrl: Vec<Vec<Vec<[f64; 4]>>> = sections
.iter()
.map(|s| {
s.curves
.iter()
.map(|c| {
let (pts, ws) = points(c);
pts.iter().zip(ws).map(|(p, w)| [p.x, p.y, p.z, w]).collect()
})
.collect()
})
.collect();
let ctrl: Vec<Vec<Vec<Vec3>>> = sections.iter().map(|s| s.curves.iter().map(|c| points(c).0).collect()).collect();
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for p in ctrl.iter().flatten().flatten() {
@@ -301,46 +162,36 @@ pub(crate) fn railed(sections: &[Section], closed: bool, rails: &[Vec<Vec3>], fi
return Err(KernelError::Invalid("two loft sections sit on top of each other".into()));
}
along.iter_mut().for_each(|u| *u /= total);
// The plain loft between the sections: each control point (and its weight) straight between
// two sections, the curve (quadratic, or cubic from four) through them across more.
// The plain loft between the sections: each control point straight between two sections, the
// curve (quadratic, or cubic from four) through them across more.
let degree = (count - 1).min(3);
let across: Vec<Vec<CurveN<4>>> = if count == 2 {
let across: Vec<Vec<CurveN<3>>> = if count == 2 {
Vec::new()
} else {
(0..n)
.map(|k| {
(0..lead.get(k).map_or(0, |c| c.ctrl.len()))
.map(|j| {
let pts: Vec<[f64; 4]> = pctrl.iter().filter_map(|s| s.get(k)?.get(j).copied()).collect();
let pts: Vec<[f64; 3]> = ctrl.iter().filter_map(|s| s.get(k)?.get(j).map(|q| [q.x, q.y, q.z])).collect();
CurveN::interpolate(&along, &pts, degree).map_err(|e| fail(&e.to_string()))
})
.collect::<Result<Vec<_>>>()
})
.collect::<Result<_>>()?
};
let plain = |v: f64| -> Result<Vec<Vec<[f64; 4]>>> {
let (Some(a), Some(b)) = (pctrl.first(), pctrl.last()) else { return Err(fail("sections")) };
let plain = |v: f64| -> Result<Vec<Vec<Vec3>>> {
let (Some(a), Some(b)) = (ctrl.first(), ctrl.last()) else { return Err(fail("sections")) };
if count == 2 {
let lerp = |p: &[f64; 4], q: &[f64; 4]| [0, 1, 2, 3].map(|i| p[i] + (q[i] - p[i]) * v);
return Ok(a.iter().zip(b).map(|(ea, eb)| ea.iter().zip(eb).map(|(p, q)| lerp(p, q)).collect()).collect());
return Ok(a.iter().zip(b).map(|(ea, eb)| ea.iter().zip(eb).map(|(p, q)| p.lerp(*q, v)).collect()).collect());
}
across.iter().map(|cs| cs.iter().map(|c| c.eval(v).map(|(h, _)| h).ok_or_else(|| fail("the plain loft"))).collect()).collect()
};
// The station curves: the lead's degree and knots, the plain loft's points and weights.
let curves_of = |h: &[Vec<[f64; 4]>]| -> Result<Vec<CurveN<4>>> {
lead.iter()
.zip(h)
.map(|(c, pts)| {
if pts.iter().any(|q| !(q[3] > 0.0) || !q.iter().all(|x| x.is_finite())) {
return Err(fail("the plain loft"));
}
let ctrl = pts.iter().map(|q| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]]).collect();
Ok(CurveN { degree: c.degree, knots: c.knots.clone(), ctrl })
})
across
.iter()
.map(|cs| cs.iter().map(|c| c.eval(v).map(|([x, y, z], _)| Vec3::new(x, y, z)).ok_or_else(|| fail("the plain loft"))).collect())
.collect()
};
let curves_of = |pts: &[Vec<Vec3>]| -> Vec<CurveN<4>> { lead.iter().zip(pts).map(|(c, p)| with(c, p)).collect() };
// The plain loft's middle and its direction there.
let middle_at = |v: f64| -> Result<Vec3> { centroid(&curves_of(&plain(v)?)?).ok_or_else(|| fail("the plain loft")) };
let middle_at = |v: f64| -> Result<Vec3> { centroid(&curves_of(&plain(v)?)).ok_or_else(|| fail("the plain loft")) };
let middle = |v: f64| -> Result<(Vec3, Vec3)> {
let h = 1e-4;
let d = middle_at((v + h).min(1.0))? - middle_at((v - h).max(0.0))?;
@@ -367,7 +218,7 @@ pub(crate) fn railed(sections: &[Section], closed: bool, rails: &[Vec<Vec3>], fi
for q in 1..=per {
let f = q as f64 / (per + 1) as f64;
let v = v0 + f * (v1 - v0);
let station = curves_of(&plain(v)?)?;
let station = curves_of(&plain(v)?);
let (mid, dir) = middle(v)?;
// The station's plane turns from one section's to the next's.
let normal = n0.lerp(n1, f).normalized().ok_or_else(|| fail("section planes"))?;
@@ -592,65 +443,3 @@ pub(crate) fn station_map(pairs: &[(Vec3, Vec3)], middle: Vec3, dir: Vec3, fit:
let shift = if two { Vec3::ZERO } else { dir * lead };
Ok(Affine { from, to: to + shift, m })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod unify_tests {
use super::*;
use solidcore_record::Vec2;
fn plane(z: f64) -> Plane {
Plane { origin: Vec3::new(0.0, 0.0, z), x: Vec3::X, y: Vec3::Y }
}
/// Each unified curve is the curve it was, point for point along its parameter.
#[test]
fn unified_curves_are_the_curves_they_were() {
let half = [
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) },
];
let rect = [
Seg2::Line { a: Vec2::new(-20.0, -10.0), b: Vec2::new(20.0, -10.0) },
Seg2::Line { a: Vec2::new(20.0, -10.0), b: Vec2::new(20.0, 10.0) },
Seg2::Line { a: Vec2::new(20.0, 10.0), b: Vec2::new(-20.0, 10.0) },
Seg2::Line { a: Vec2::new(-20.0, 10.0), b: Vec2::new(-20.0, -10.0) },
];
// Arc against line, line against line, quarter arc against third-of-a-turn arc.
let pie = |sweep: f64| {
let end = Vec2::new(10.0 * sweep.cos(), 10.0 * sweep.sin());
[
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep },
Seg2::Line { a: end, b: Vec2::ZERO },
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(10.0, 0.0) },
]
};
let cases: Vec<Vec<Section>> = vec![
vec![
Section::of(&plane(0.0), &half, vec![vec![0], vec![1]]).unwrap(),
Section::of(&plane(40.0), &rect[..2], vec![vec![0], vec![1]]).unwrap(),
],
vec![
Section::of(&plane(0.0), &pie(std::f64::consts::FRAC_PI_2), vec![vec![0], vec![1], vec![2]]).unwrap(),
Section::of(&plane(40.0), &pie(2.0 * std::f64::consts::PI / 3.0), vec![vec![0], vec![1], vec![2]]).unwrap(),
],
];
for sections in &cases {
let out = unified(sections).unwrap();
for (s, u) in sections.iter().zip(&out) {
for (c, d) in s.curves.iter().zip(&u.curves) {
assert_eq!(d.knots.len(), d.ctrl.len() + d.degree + 1, "{d:?}");
for k in 0..=100 {
let f = k as f64 / 100.0;
let (p, q) = (point_at(c, f).unwrap(), point_at(d, f).unwrap());
assert!(p.dist(q) < 1e-9, "at {f}: {p:?} vs {q:?}\n{c:?}\n{d:?}");
}
}
let lead = &out[0].curves;
for (c, d) in lead.iter().zip(&u.curves) {
assert_eq!((c.degree, &c.knots, c.ctrl.len()), (d.degree, &d.knots, d.ctrl.len()));
}
}
}
}
}
-70
View File
@@ -169,76 +169,6 @@ fn rails_the_loft_cannot_follow_are_refused() {
}
}
/// A pie of `sweep` radians and radius 10 about the origin: the arc, then two lines through the
/// centre.
fn pie(sweep: f64) -> Loop2 {
let end = Vec2::new(10.0 * sweep.cos(), 10.0 * sweep.sin());
Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep },
Seg2::Line { a: end, b: Vec2::ZERO },
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(10.0, 0.0) },
],
}
}
/// A rail straight up from `p` (x, y) from z = 0 to z = `top`.
fn straight(p: Vec2, top: f64) -> Vec<Vec3> {
(0..=80).map(|i| f64::from(i) * top / 80.0).map(|z| Vec3::new(p.x, p.y, z)).collect()
}
/// Sections whose curves differ in kind take rails (#12512): a half-disc (an arc and a line)
/// lofted to itself along a straight rail up the arc is the plain loft, the half-cylinder, exactly
/// (its round wall is the circle at every height); and the half-disc lofts to a rectangle along a
/// bulging rail, which it follows (the rail's point on the sections drifts from the arc's top
/// to the middle of the rectangle's edge, so the scaled stations reach past the rail).
#[test]
fn sections_of_an_arc_and_a_line_take_rails() {
let half = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI },
Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) },
],
};
let sections = [(at(0.0), half.clone()), (at(40.0), half.clone())];
let b = loft_with_rails(&sections, &[straight(Vec2::new(0.0, 10.0), 40.0)], RailFit::Scale).unwrap();
let plain = volume(&loft(&sections).unwrap());
assert!(close(volume(&b), 2000.0 * PI, 1e-3), "volume {} vs {}", volume(&b), 2000.0 * PI);
assert!(close(volume(&b), plain, 1e-6), "{} vs the plain loft's {}", volume(&b), plain);
for z in [5.0, 13.0, 20.0, 31.0] {
for a in [0.3, 1.1, PI / 2.0, 2.4, 2.9] {
let p = Vec3::new(10.0 * a.cos(), 10.0 * a.sin(), z);
assert!(off(&b, p) < 1e-6, "{p:?} is {} mm off the half-cylinder", off(&b, p));
}
}
// The reporter's case: the half-disc to a rectangle, with a rail bulging out from the arc.
let rect = Loop2::polygon(&[Vec2::new(-20.0, -10.0), Vec2::new(20.0, -10.0), Vec2::new(20.0, 10.0), Vec2::new(-20.0, 10.0)]);
let sections = [(at(0.0), half), (at(40.0), rect)];
let r: Vec<Vec3> = (0..=80).map(|i| f64::from(i) * 0.5).map(|z| Vec3::new(0.0, 10.0 + 5.0 * bulge(z), z)).collect();
let b = loft_with_rails(&sections, std::slice::from_ref(&r), RailFit::Scale).unwrap();
let m = measure(&b).unwrap();
let worst = r.iter().map(|p| off(&b, *p)).fold(0.0, f64::max);
assert!(worst < 1e-3, "the rail is {worst} mm off the loft");
assert!(m.volume > 40.0 * 50.0 * PI && m.volume < 40.0 * 800.0, "volume {}", m.volume);
assert!(m.bbox.max.y > 15.0 - 1e-2 && m.bbox.min.z.abs() < 1e-9 && (m.bbox.max.z - 40.0).abs() < 1e-9, "{:?}", m.bbox);
}
/// Arcs of different turns (one piece against two, different weights) pair up too: a quarter pie
/// to a third-of-a-turn pie along a straight rail up their shared centre corner is the plain
/// loft to a tenth of a percent (the stations between two arcs of different weights are not
/// the ruled surface's sections exactly).
#[test]
fn arcs_of_different_turns_take_rails() {
let sections = [(at(0.0), pie(PI / 2.0)), (at(40.0), pie(2.0 * PI / 3.0))];
let r = straight(Vec2::ZERO, 40.0);
let b = loft_with_rails(&sections, std::slice::from_ref(&r), RailFit::Scale).unwrap();
let plain = volume(&loft(&sections).unwrap());
assert!(close(volume(&b), plain, 1e-3), "{} vs the plain loft's {}", volume(&b), plain);
assert!(volume(&b) > 40.0 * 25.0 * PI && volume(&b) < 40.0 * 100.0 * PI / 3.0, "volume {}", volume(&b));
let worst = r.iter().map(|p| off(&b, *p)).fold(0.0, f64::max);
assert!(worst < 1e-6, "the rail is {worst} mm off the loft");
}
/// A section on the plane through the origin with axes `x` and `y` (normal x × y), with no curves:
/// enough for the normals.
fn facing(x: Vec3, y: Vec3) -> crate::loft_rails::Section {
+4 -23
View File
@@ -906,27 +906,7 @@ mod imp {
let c = curves(plane, pieces)?;
let route = path.iter().map(path_curve).collect::<Result<Vec<_>>>()?;
let frame = if translate { SweepFrame::Translate } else { SweepFrame::Follow };
Brep::sweep_placed(&c, &route, frame, true).map_err(|e| sweep_failed(path, e))
}
/// SMLib's refusal of a sweep along `path`, in words where its status says why: an arc of the
/// path is swept as a turn about the arc's axis, and `SM_ERR_AXIS_INSIDE_FACE` (1025) is the
/// profile reaching across that axis, past the centre of the bend (#12126).
fn sweep_failed(path: &[crate::PathSeg], e: solidcraft_smlib::Error) -> KernelError {
let tightest = path
.iter()
.filter_map(|s| match *s {
crate::PathSeg::Arc { a, center, .. } => Some(a.dist(center)),
_ => None,
})
.min_by(f64::total_cmp);
match (&e, tightest) {
(solidcraft_smlib::Error::Status(1025), Some(radius)) => KernelError::Invalid(format!(
"the profile reaches past the centre of a bend in the path: it must stay inside the radius of every bend it turns towards (the tightest bend here is {} mm)",
crate::blend::trimmed_mm(radius)
)),
_ => failed("sweep", e),
}
Brep::sweep_placed(&c, &route, frame, true).map_err(|e| failed("sweep", e))
}
/// `q` moved by the motion `a`.
@@ -1947,8 +1927,9 @@ mod imp {
/// A loft through `sections` (as [`loft`] takes them) guided by one or two `rails` (polylines
/// from the first section to the last, through every section's curve), by the law
/// [`crate::loft_rails::railed`] gives a loft's rails: between each two sections stations are
/// the plain loft there moved so that the points the rails guide land on the rails. The
/// sections' curves are brought to one form there, so a line pairs with an arc (#12512).
/// the plain loft there moved so that the points the rails guide land on the rails. Each
/// section curve has to pair with the others' (the same kind, so their control points pair
/// up).
pub(crate) fn loft_rails(sections: &[(Plane, Vec<Piece>)], first: usize, rails: &[Vec<Vec3>], fit: crate::RailFit) -> Result<Body> {
if rails.is_empty() || rails.len() > 2 {
return Err(KernelError::Invalid("a loft takes one or two guide rails".into()));
+2 -4
View File
@@ -974,11 +974,9 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
/// directions looks longer on screen (`to`: sketch to screen). Seen nearly edge-on, one of them
/// points at the eye and looks a pixel long or less: sized from it alone, a dimension's text and
/// gaps grew so large that its lines ran across the window (#14826). Face-on both are the same.
/// A direction that does not project leaves the other to size it; with neither, there is no size.
pub(crate) fn mm_per_pixel(to: impl Fn(Vec2) -> Option<Pos2>, at: Vec2) -> Option<f64> {
let a = to(at)?;
let longest = [Vec2::X, Vec2::Y].into_iter().filter_map(|d| to(at + d)).map(|p| a.distance(p)).reduce(f32::max)?;
let px = 1.0 / f64::from(longest).max(1e-9);
let (a, x, y) = (to(at)?, to(at + Vec2::X)?, to(at + Vec2::Y)?);
let px = 1.0 / f64::from(a.distance(x).max(a.distance(y))).max(1e-9);
px.is_finite().then_some(px)
}
@@ -1929,25 +1929,6 @@ fn a_sketch_seen_edge_on_keeps_its_dimension_lines_near_it() {
}
}
/// A dimension is still sized, from the direction that does reach the screen, when one of the
/// sketch's x and y directions does not project (for example it ends behind the eye).
#[test]
fn a_dimension_is_sized_from_whichever_direction_reaches_the_screen() {
use crate::dim_view::mm_per_pixel;
use solidcore_record::Vec2;
let at = Vec2::new(3.0, 4.0);
// 1 mm along x is 4 px, 1 mm along y is 2 px.
let screen = |p: Vec2| egui::Pos2::new(4.0 * p.x as f32, 2.0 * p.y as f32);
assert_eq!(mm_per_pixel(|p| Some(screen(p)), at), Some(0.25), "the longer direction sizes it");
let only_x = |p: Vec2| (p.y == at.y).then(|| screen(p));
assert_eq!(mm_per_pixel(only_x, at), Some(0.25), "y does not project: x alone sizes it");
let only_y = |p: Vec2| (p.x == at.x).then(|| screen(p));
assert_eq!(mm_per_pixel(only_y, at), Some(0.5), "x does not project: y alone sizes it");
let neither = |p: Vec2| (p.x == at.x && p.y == at.y).then(|| screen(p));
assert_eq!(mm_per_pixel(neither, at), None, "no direction projects: no size");
assert_eq!(mm_per_pixel(|_| None, at), None, "the point itself does not project");
}
#[test]
fn escape_cancels_a_shown_dimensions_value() {
let (mut h, _) = shown_dimensions();
@@ -8,6 +8,7 @@ use syn::{Expr, Item, Lit};
/// These `.size()` calls size spinner geometry, not text. No text sizes are exempt.
const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("dialogs.rs", 2, "preview spinners: graphic diameter, not typography"),
("viewport.rs", 1, "viewport status spinner: graphic diameter, not typography"),
("ui_kit/settings_search.rs", 2, "hidden off-screen areas that measure search matches: never drawn"),
];
-1
View File
@@ -1018,7 +1018,6 @@ fn bar(app: &SolveApp, floating: bool) -> history_bar::Bar<'static> {
marker: 0,
steps: 0,
floating,
reorder: true,
handles: BAR_HANDLES,
}
}
+334 -16
View File
@@ -2,6 +2,7 @@
//! view and the navigation bar.
use crate::ui_kit::BackingTooltipExt as _;
use crate::ui_kit::{Container, Surface};
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use serde_json::json;
use solidcore_record::{Plane, Vec2, Vec3};
@@ -6228,14 +6229,9 @@ impl ToastPlace {
}
}
// The view toast is the kit's (`ui_kit::toast`): its card, buttons, × and countdown ring. Every
// toast's width in the corner stack (`TOAST_WIDTH`) leaves room for three buttons on one row (the 3D
// mouse offer's Calibrate…, Use Direct Driver and Never show again, #7971); longer text wraps.
pub(crate) use crate::ui_kit::toast::{
Action as ToastKind, Extras as ToastExtras, Lead as ToastLead, Shown as ToastShown, WIDTH as TOAST_WIDTH, countdown_left,
};
#[cfg(test)]
use crate::ui_kit::toast::{action_spots as toast_action_spots, action_spots_trailing as toast_action_spots_trailing, countdown_arc};
/// Every toast's width in the corner stack: room for three buttons on one row (the 3D mouse offer's
/// Calibrate…, Use Direct Driver and Never show again, #7971); longer text wraps.
pub(crate) const TOAST_WIDTH: f32 = 360.0;
/// Where the corner stack may go: the largest free rect in a `TOAST_WIDTH` column at the right of
/// `view`, clear of `obstacles` (the axis view and cube at the top, the view toolbar when a narrow
@@ -6276,6 +6272,106 @@ pub(crate) fn corner_free(view: Rect, obstacles: &[Rect]) -> Rect {
best
}
/// What a toast's button is for, which sets its look and where it goes: the main action first
/// (the accent button), then an alternative, then the quiet dismissals.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum ToastKind {
Primary,
Secondary,
Dismiss,
}
/// Where each button of a toast goes, relative to the row's top left: `buttons` are (width, kind) in
/// the order given; the primary and the alternative form one group and the dismissals another.
/// Groups go side by side while they fit in `row_width` and wrap as a whole, so a button is not
/// left alone on a row while its group-mate fits beside it; a group wider than the row wraps
/// button by button.
fn toast_action_spots(buttons: &[(f32, ToastKind)], row_width: f32, gap: f32, height: f32) -> Vec<Rect> {
let mut spots = vec![Rect::NOTHING; buttons.len()];
let groups: [Vec<usize>; 2] = [
(0..buttons.len()).filter(|&i| buttons[i].1 != ToastKind::Dismiss).collect(),
(0..buttons.len()).filter(|&i| buttons[i].1 == ToastKind::Dismiss).collect(),
];
let (mut x, mut y) = (0.0_f32, 0.0_f32);
for group in groups.iter().filter(|g| !g.is_empty()) {
let whole: f32 = group.iter().map(|&i| buttons[i].0).sum::<f32>() + gap * (group.len() - 1) as f32;
if x > 0.0 && x + whole > row_width {
(x, y) = (0.0, y + height + gap);
}
for &i in group {
let width = buttons[i].0;
// Only a group wider than a whole row wraps inside itself.
if x > 0.0 && x + width > row_width.max(width) && whole > row_width {
(x, y) = (0.0, y + height + gap);
}
spots[i] = Rect::from_min_size(pos2(x, y), vec2(width, height));
x += width + gap;
}
x += gap;
}
spots
}
/// The corner stack's buttons, as the dialogs place theirs: right-aligned, dismissals first, then
/// the alternative, then the main action right-most. Rows fill from the main action leftwards; a
/// button that doesn't fit starts a row above, so the row with the main action is the bottom one.
/// Positions are relative to the rows' top left, within `row_width`.
fn toast_action_spots_trailing(buttons: &[(f32, ToastKind)], row_width: f32, gap: f32, height: f32) -> Vec<Rect> {
let rank = |kind: ToastKind| match kind {
ToastKind::Dismiss => 0,
ToastKind::Secondary => 1,
ToastKind::Primary => 2,
};
let mut order: Vec<usize> = (0..buttons.len()).collect();
order.sort_by_key(|&i| rank(buttons[i].1));
let mut rows: Vec<Vec<usize>> = Vec::new();
let mut used = 0.0_f32;
for &i in order.iter().rev() {
let width = buttons[i].0;
match rows.last_mut() {
Some(row) if used + gap + width <= row_width => {
row.push(i);
used += gap + width;
}
_ => {
rows.push(vec![i]);
used = width;
}
}
}
let mut spots = vec![Rect::NOTHING; buttons.len()];
let count = rows.len();
for (k, row) in rows.iter().enumerate() {
let y = (count - 1 - k) as f32 * (height + gap);
let mut x = row_width;
for &i in row {
x -= buttons[i].0;
if let Some(spot) = spots.get_mut(i) {
*spot = Rect::from_min_size(pos2(x, y), vec2(buttons[i].0, height));
}
x -= gap;
}
}
spots
}
/// Where a toast button's drawn width is remembered: by its text and kind, which set its look.
fn toast_button_key(label: &str, kind: ToastKind) -> egui::Id {
egui::Id::unique(("sc_toast_button_width", label, kind))
}
/// The width a toast button with this text and kind was last drawn at, if it has been drawn.
fn toast_button_width(ctx: &egui::Context, label: &str, kind: ToastKind) -> Option<f32> {
ctx.data(|data| data.get_temp::<f32>(toast_button_key(label, kind))).filter(|width| width.is_finite() && *width > 0.0)
}
/// What leads a toast's text: a dot (red for an error) or a spinner.
#[derive(Clone, Copy)]
enum ToastLead {
Dot(bool),
Spinner,
}
fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing: bool) -> Option<Rect> {
let lead = if computing { ToastLead::Spinner } else { ToastLead::Dot(error) };
paint_toast_with(ui, free, text, lead, &[]).map(|(rect, _, _)| rect)
@@ -6293,10 +6389,100 @@ fn paint_toast_with(
paint_toast_ext(ui, free, text, lead, actions, ToastExtras::default()).map(|toast| (toast.rect, toast.clicked, toast.buttons))
}
/// The one toast painter: the kit's toast ([`crate::ui_kit::toast::show`]) in `free`, where
/// Preferences › Interface › Notices places it ([`ToastPlace`]). With no actions and no extras the
/// card is exactly the status and computing toast it always was. The buttons and the × are not
/// live while an issue report holds CAD input.
/// What a toast has besides its text and buttons (#7971).
#[derive(Clone, Copy, Default)]
pub(crate) struct ToastExtras<'a> {
/// A close (×) in its top-right corner, published as this control-channel handle
/// (`toast:<name>:close`). Every toast the user may close has one, in place of a Close button.
pub close: Option<&'a str>,
/// For a toast that goes by itself: the share of its time left (1 to 0) and its whole time in
/// seconds, drawn as a thin ring around the × that shrinks over its time.
pub countdown: Option<(f32, f64)>,
/// Hover texts of the buttons, by index; an empty one has none.
pub tips: &'a [&'a str],
/// The whole card answers a click, under its buttons and ×, with this hover text.
pub card_hint: Option<&'a str>,
}
/// A toast as drawn: its card, which button was clicked, if any, where each button and the × are
/// (for handles), and whether the × or the card itself was clicked.
pub(crate) struct ToastShown {
pub rect: Rect,
pub clicked: Option<usize>,
pub buttons: Vec<Rect>,
pub close: Option<Rect>,
pub closed: bool,
pub card_clicked: bool,
}
/// The share of a countdown of `lifetime` seconds left at `now` when it started at `since`: 1 when
/// it starts, 0 when it is over, and 1 for times no clock gives (non-finite, or a start in the future).
pub(crate) fn countdown_left(since: f64, now: f64, lifetime: f64) -> f32 {
let left = 1.0 - (now - since) / lifetime;
if left.is_finite() { left.clamp(0.0, 1.0) as f32 } else { 1.0 }
}
/// The countdown ring's radius around a toast's ×; the × is drawn inside it and its hit target is
/// the ring's square.
fn toast_close_radius() -> f32 {
Tokens::get().space_10
}
/// The points of the countdown ring: an arc of `left` of a turn round `centre`, from twelve o'clock
/// clockwise, so it shrinks back towards twelve o'clock as the time goes. Empty when nothing is left.
pub(crate) fn countdown_arc(centre: Pos2, radius: f32, left: f32) -> Vec<Pos2> {
let left = if left.is_finite() { left.clamp(0.0, 1.0) } else { 0.0 };
if left <= 0.0 || !radius.is_finite() || radius <= 0.0 {
return Vec::new();
}
// One segment per 7.5 degrees of the arc, at least one.
let segments = ((48.0 * left).ceil() as usize).max(1);
(0..=segments)
.map(|i| {
let angle = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * left * i as f32 / segments as f32;
centre + radius * vec2(angle.cos(), angle.sin())
})
.collect()
}
/// A toast's close (×) at `rect`, with its countdown ring when `left` is given: a kit-drawn button
/// with an accessible name and hover text (`ui.close`), reachable by Tab and taken with Enter or Space.
fn toast_close(ui: &egui::Ui, rect: Rect, id: egui::Id, left: Option<f32>) -> egui::Response {
let t = Tokens::get();
let response = ui.interact(rect, id, Sense::click());
let painter = ui.painter();
let centre = rect.center();
let radius = rect.width().min(rect.height()) * 0.5;
let lit = response.hovered() || response.has_focus();
if lit && ui.is_enabled() {
painter.circle_filled(centre, radius, t.hover);
}
if response.has_focus() {
painter.circle_stroke(centre, radius + t.space_2, Stroke::new(1.0, t.form_primary));
}
if let Some(left) = left {
let arc = countdown_arc(centre, radius - 0.75, left);
if arc.len() >= 2 {
painter.add(egui::Shape::line(arc, Stroke::new(1.5, t.text_dim)));
}
}
let ink = if lit && ui.is_enabled() { t.text } else { t.text_dim };
crate::icons::paint_ink(painter, Rect::from_center_size(centre, vec2(t.space_10, t.space_10)), "close", ink);
let label = solidcraft_ui_text::tr("ui.close");
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
if response.hovered() && ui.is_enabled() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response.backing_on_hover_text(label)
}
/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a
/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons), with what
/// `extras` adds (a close (×) at the top right with its countdown ring, hover texts, a click on the
/// card). Returns the card, which button was clicked, if any, and where each button is (for
/// handles). With no actions and no extras the card is exactly the status and computing toast it
/// always was. The buttons and the × are not live while an issue report holds CAD input; none is
/// laid outside the card, whose width is bounded by `free`.
fn paint_toast_ext(
ui: &egui::Ui,
free: Rect,
@@ -6305,11 +6491,143 @@ fn paint_toast_ext(
actions: &[(&str, ToastKind)],
extras: ToastExtras<'_>,
) -> Option<ToastShown> {
let place = match ToastPlace::current(ui.ctx()) {
ToastPlace::Corner => crate::ui_kit::toast::Place::Corner,
ToastPlace::Centre => crate::ui_kit::toast::Place::Centre,
// Rects no screen has are rejected too: the corner layout anchors to `free`'s right edge.
if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 || free.max.x.abs().max(free.max.y.abs()) > 1.0e7 {
return None;
}
let t = Tokens::get();
let corner = ToastPlace::current(ui.ctx()) == ToastPlace::Corner;
let painter = ui.painter().with_clip_rect(free);
// In the corner every toast is `TOAST_WIDTH` wide (or the room there is); at the centre each
// is as wide as its text, up to 440 points.
let wrap = if corner { (TOAST_WIDTH.min(free.width()) - 40.0).max(0.0) } else { (free.width() - 40.0).min(440.0) };
// The × keeps the top-right corner: the text stops short of its ring.
let radius = toast_close_radius();
let close_room = if extras.close.is_some() { 2.0 * radius + t.space_4 + t.space_8 - 12.0 } else { 0.0 };
let galley = painter.layout(text, crate::ui_kit::type_face::medium(t.font_label), t.text, (wrap - close_room).max(0.0));
// The buttons: each as wide as its text and a margin, in rows no wider than the text may be.
let gap = t.space_8;
let button_height = crate::ui_kit::row_height();
let row_width = if corner { wrap } else { (free.width() - 40.0).clamp(0.0, 440.0) };
// Each button's width as the kit drew it (#7971): the same text, font and padding, measured
// from the button itself. Before a button was ever drawn, an estimate stands in for one frame.
let sizes: Vec<(f32, ToastKind)> = actions
.iter()
.map(|(label, kind)| {
let width = toast_button_width(ui.ctx(), label, *kind).unwrap_or_else(|| {
let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 };
painter.layout_no_wrap((*label).to_string(), crate::ui_kit::type_face::medium(t.font_label), t.text).size().x + 2.0 * padding
});
(width.min(row_width), *kind)
})
.collect();
let spots = if corner {
toast_action_spots_trailing(&sizes, row_width, gap, button_height)
} else {
toast_action_spots(&sizes, row_width, gap, button_height)
};
crate::ui_kit::toast::show(ui, free, place, text, lead, actions, extras)
let widest = spots.iter().map(|spot| spot.right()).fold(0.0, f32::max);
let y = spots.iter().map(|spot| spot.top()).fold(0.0, f32::max);
let actions_height = if spots.is_empty() { 0.0 } else { y + button_height + gap };
let content = vec2((galley.size().x + close_room).max(widest), galley.size().y + actions_height);
let mut size = content + vec2(40.0, 16.0);
if corner {
size.x = row_width + 40.0;
}
if extras.close.is_some() {
size.y = size.y.max(2.0 * radius + 12.0);
}
if !size.is_finite() || size.x > free.width() || size.y > free.height() {
return None;
}
let x = if corner { free.right() - size.x * 0.5 } else { free.center().x };
let r = Rect::from_center_size(pos2(x, free.bottom() - size.y * 0.5), size);
if !r.is_finite() {
return None;
}
let frame = Container::new(ui.ctx(), Surface::Toast).frame(ui.ctx(), t.panel, crate::frame::Side::Left);
Container::paint_frame(ui.painter(), frame, r.shrink(frame.stroke.width));
let dot = pos2(r.left() + 14.0, r.center().y.min(r.top() + 8.0 + galley.size().y / 2.0));
match lead {
ToastLead::Spinner => egui::Spinner::new().size(12.0).color(t.text).paint_at(ui, Rect::from_center_size(dot, vec2(12.0, 12.0))),
ToastLead::Dot(error) => drop(painter.circle_filled(dot, 3.0, if error { t.error } else { t.text })),
}
let text_top = r.min + vec2(28.0, 8.0);
painter.galley(text_top, galley.clone(), t.text);
// The card's own click goes first, so its buttons and × (added after it) take theirs. Behind an
// issue report it is dead too, so a click on the dead × doesn't fall through to it.
let blocked = crate::ui_kit::input_blocked();
let card_clicked = extras.card_hint.is_some_and(|hint| {
let sense = if blocked { Sense::hover() } else { Sense::click() };
ui.interact(r, egui::Id::unique(("sc_toast_card", extras.close.unwrap_or_default())), sense).backing_on_hover_text(hint).clicked()
});
let mut clicked = None;
let mut buttons = Vec::with_capacity(actions.len());
let origin = text_top + vec2(0.0, galley.size().y + gap);
for (index, ((label, kind), spot)) in actions.iter().zip(&spots).enumerate() {
let at = spot.translate(origin.to_vec2());
// A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`. It has the
// row's width, so a button is never squeezed into a spot laid out for a smaller one and
// its measured width is its own.
let room = Rect::from_min_size(at.min, vec2(row_width.max(at.width()), at.height()));
let mut button_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::unique(("sc_toast_action", index)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Center)),
);
button_ui.set_clip_rect(button_ui.clip_rect().intersect(r));
let response = button_ui
.add_enabled_ui(!blocked, |ui| match kind {
ToastKind::Primary => crate::ui_kit::primary_button(ui, label),
ToastKind::Secondary => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Normal),
ToastKind::Dismiss => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Small),
})
.inner;
let response = match extras.tips.get(index).filter(|tip| !tip.is_empty()) {
Some(tip) => response.backing_on_hover_text(*tip),
None => response,
};
if response.clicked() {
clicked = Some(index);
}
// Remember the width it was drawn at; laid out at another, the toast is laid out again next frame.
let drawn = response.rect.width();
if drawn.is_finite() && drawn > 0.0 {
if sizes.get(index).is_some_and(|(used, _)| (used - drawn).abs() > 0.5 && drawn < row_width) {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|data| data.insert_temp(toast_button_key(label, *kind), drawn));
}
buttons.push(response.rect);
}
// The close (×), top right, level with the text's first line.
let mut close = None;
let mut closed = false;
if let Some(handle) = extras.close {
let line = painter.layout_no_wrap("A".into(), crate::ui_kit::type_face::medium(t.font_label), t.text).size().y;
let centre = pos2(r.right() - t.space_8 - radius, (text_top.y + line * 0.5).max(r.top() + 6.0 + radius));
let at = Rect::from_center_size(centre, vec2(2.0 * radius, 2.0 * radius));
let mut close_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::unique(("sc_toast_close_ui", handle)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at),
);
close_ui.set_clip_rect(close_ui.clip_rect().intersect(r));
if blocked {
close_ui.disable();
}
let left = extras.countdown.map(|(left, _)| left);
closed = toast_close(&close_ui, at, egui::Id::unique(("sc_toast_close", handle)), left).clicked();
crate::scenario::publish_handle(handle, centre);
close = Some(at);
// A frame each time the ring's end moves about a point, not one per display refresh.
if let Some((_, lifetime)) = extras.countdown {
let every = lifetime / (std::f64::consts::TAU * f64::from(radius));
let every = if every.is_finite() { every.clamp(0.05, 1.0) } else { 1.0 };
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(every));
}
}
Some(ToastShown { rect: r, clicked, buttons, close, closed, card_clicked })
}
/// A click without a command: select the candidate (replacing the selection, or adding /
+2 -6
View File
@@ -47,9 +47,6 @@ pub struct Bar<'a> {
pub steps: usize,
/// The floating card, which also dots the last step before the marker.
pub floating: bool,
/// Steps can be dragged to a new place ([`Action::Reorder`]). Off for a history whose steps
/// have no order to change: a step then senses clicks only, with no drag and no drop.
pub reorder: bool,
pub handles: Handles<'a>,
}
@@ -404,8 +401,7 @@ impl Strip<'_, '_> {
if !ui.clip_rect().intersects(br) && !dragged && !ui.memory(|m| m.has_focus(chip_id)) && !egui::Popup::is_any_open(ui.ctx()) {
return br;
}
let sense = if self.bar.reorder { Sense::click_and_drag() } else { Sense::click() };
let resp = ui.interact(br, chip_id, sense);
let resp = ui.interact(br, chip_id, Sense::click_and_drag());
if resp.hovered() {
self.hovered = Some(step.id);
}
@@ -427,7 +423,7 @@ impl Strip<'_, '_> {
if self.bar.floating && step.state.last_done {
self.painter.circle_filled(pos2(br.center().x, br.bottom() + t.space_3), t.space_2, self.bar.accent.color);
}
if self.bar.reorder && resp.drag_started() {
if resp.drag_started() {
self.drag = Some(Drag::Step(step.id));
}
let resp = resp.backing_on_hover_ui(|ui| {
-14
View File
@@ -18,7 +18,6 @@ fn bar(steps: usize, marker: usize, play: bool) -> Bar<'static> {
marker,
steps,
floating: false,
reorder: true,
handles: HANDLES,
}
}
@@ -200,19 +199,6 @@ fn a_dragged_step_returns_reorder_with_the_slot_and_side_of_the_marker() {
assert_eq!(rig.drag(a, right), vec![Action::Reorder { step: 1, slot: 2, before: false }], "the marker's slot, after it");
}
#[test]
fn without_reorder_a_dragged_step_returns_nothing_and_a_click_still_selects() {
let mut rig = Rig::new(&["A", "B", "C"], 2);
rig.bar.reorder = false;
let (a, c) = (rig.at("test:A"), rig.at("test:C"));
let end = c + vec2(32.0 - 14.0 + 2.0, 0.0);
assert_eq!(rig.drag(a, end), Vec::new(), "no reorder");
assert_eq!(rig.drag(c, a - vec2(12.0, 0.0)), Vec::new());
assert_eq!(rig.click(c), vec![Action::Select { step: 3, modifiers: Modifiers::NONE }]);
// the marker still drags
assert_eq!(rig.drag(rig.marker(), a - vec2(12.0, 0.0)), vec![Action::Seek(Seek::Slot { slot: 0, before: true })]);
}
#[test]
fn the_dragged_marker_returns_seek_and_a_drag_that_leaves_the_window_returns_nothing() {
let rig = Rig::new(&["A", "B", "C"], 2);
-1
View File
@@ -19,7 +19,6 @@ pub mod ribbon;
pub mod start_page;
pub mod theme_files;
pub mod theme_options;
pub mod toast;
pub mod view_compass;
pub mod window_chrome;
-363
View File
@@ -1,363 +0,0 @@
//! The view toast: a one-message card over the view's free part, for a hint, a result, a refusal
//! or work in progress. Its text is led by a dot (red for an error) or a spinner; below the text,
//! a wrapping row of kit buttons; a close (×) at the top right, with a ring that shrinks over the
//! time left of a toast that goes by itself. It sits at the bottom centre of the free part, as wide
//! as its text, or in a column at its bottom right, every toast [`WIDTH`] wide, with its buttons
//! placed as the dialogs place theirs. Everything comes from the caller's plain data and
//! `solidcraft_theme::Tokens`; the toast returns what was clicked and where it drew.
use crate::BackingTooltipExt as _;
use crate::{Container, Surface};
use egui::{Pos2, Rect, Sense, Stroke, pos2, vec2};
use solidcraft_theme::Tokens;
/// Every toast's width in the corner column: room for three buttons on one row; longer text wraps.
pub const WIDTH: f32 = 360.0;
/// Where a toast goes in the free part it is given.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Place {
/// At the bottom right, every toast [`WIDTH`] wide (or the room there is), its buttons as in
/// the dialogs: right-aligned, dismissals first and the main action right-most.
#[default]
Corner,
/// At the bottom centre, each as wide as its text, up to 440 points.
Centre,
}
/// What a toast's button is for, which sets its look and where it goes: the main action first
/// (the accent button), then an alternative, then the quiet dismissals.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Action {
Primary,
Secondary,
Dismiss,
}
/// What leads a toast's text: a dot (red when `true`, for an error) or a spinner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lead {
Dot(bool),
Spinner,
}
/// What a toast has besides its text and buttons.
#[derive(Clone, Copy, Default)]
pub struct Extras<'a> {
/// A close (×) in its top-right corner, published as this control-channel handle
/// (`toast:<name>:close`). Every toast the user may close has one, in place of a Close button.
pub close: Option<&'a str>,
/// For a toast that goes by itself: the share of its time left (1 to 0) and its whole time in
/// seconds, drawn as a thin ring around the × that shrinks over its time.
pub countdown: Option<(f32, f64)>,
/// Hover texts of the buttons, by index; an empty one has none.
pub tips: &'a [&'a str],
/// The whole card answers a click, under its buttons and ×, with this hover text.
pub card_hint: Option<&'a str>,
}
/// A toast as drawn: its card, which button was clicked, if any, where each button and the × are
/// (for handles), and whether the × or the card itself was clicked.
pub struct Shown {
pub rect: Rect,
pub clicked: Option<usize>,
pub buttons: Vec<Rect>,
pub close: Option<Rect>,
pub closed: bool,
pub card_clicked: bool,
}
/// Where each button of a toast goes, relative to the row's top left: `buttons` are (width, kind) in
/// the order given; the primary and the alternative form one group and the dismissals another.
/// Groups go side by side while they fit in `row_width` and wrap as a whole, so a button is not
/// left alone on a row while its group-mate fits beside it; a group wider than the row wraps
/// button by button.
pub fn action_spots(buttons: &[(f32, Action)], row_width: f32, gap: f32, height: f32) -> Vec<Rect> {
let mut spots = vec![Rect::NOTHING; buttons.len()];
let groups: [Vec<usize>; 2] = [
(0..buttons.len()).filter(|&i| buttons[i].1 != Action::Dismiss).collect(),
(0..buttons.len()).filter(|&i| buttons[i].1 == Action::Dismiss).collect(),
];
let (mut x, mut y) = (0.0_f32, 0.0_f32);
for group in groups.iter().filter(|g| !g.is_empty()) {
let whole: f32 = group.iter().map(|&i| buttons[i].0).sum::<f32>() + gap * (group.len() - 1) as f32;
if x > 0.0 && x + whole > row_width {
(x, y) = (0.0, y + height + gap);
}
for &i in group {
let width = buttons[i].0;
// Only a group wider than a whole row wraps inside itself.
if x > 0.0 && x + width > row_width.max(width) && whole > row_width {
(x, y) = (0.0, y + height + gap);
}
spots[i] = Rect::from_min_size(pos2(x, y), vec2(width, height));
x += width + gap;
}
x += gap;
}
spots
}
/// The corner column's buttons, as the dialogs place theirs: right-aligned, dismissals first, then
/// the alternative, then the main action right-most. Rows fill from the main action leftwards; a
/// button that doesn't fit starts a row above, so the row with the main action is the bottom one.
/// Positions are relative to the rows' top left, within `row_width`.
pub fn action_spots_trailing(buttons: &[(f32, Action)], row_width: f32, gap: f32, height: f32) -> Vec<Rect> {
let rank = |kind: Action| match kind {
Action::Dismiss => 0,
Action::Secondary => 1,
Action::Primary => 2,
};
let mut order: Vec<usize> = (0..buttons.len()).collect();
order.sort_by_key(|&i| rank(buttons[i].1));
let mut rows: Vec<Vec<usize>> = Vec::new();
let mut used = 0.0_f32;
for &i in order.iter().rev() {
let width = buttons[i].0;
match rows.last_mut() {
Some(row) if used + gap + width <= row_width => {
row.push(i);
used += gap + width;
}
_ => {
rows.push(vec![i]);
used = width;
}
}
}
let mut spots = vec![Rect::NOTHING; buttons.len()];
let count = rows.len();
for (k, row) in rows.iter().enumerate() {
let y = (count - 1 - k) as f32 * (height + gap);
let mut x = row_width;
for &i in row {
x -= buttons[i].0;
if let Some(spot) = spots.get_mut(i) {
*spot = Rect::from_min_size(pos2(x, y), vec2(buttons[i].0, height));
}
x -= gap;
}
}
spots
}
/// Where a toast button's drawn width is remembered: by its text and kind, which set its look.
fn button_key(label: &str, kind: Action) -> egui::Id {
egui::Id::unique(("sc_toast_button_width", label, kind))
}
/// The width a toast button with this text and kind was last drawn at, if it has been drawn.
fn button_width(ctx: &egui::Context, label: &str, kind: Action) -> Option<f32> {
ctx.data(|data| data.get_temp::<f32>(button_key(label, kind))).filter(|width| width.is_finite() && *width > 0.0)
}
/// The share of a countdown of `lifetime` seconds left at `now` when it started at `since`: 1 when
/// it starts, 0 when it is over, and 1 for times no clock gives (non-finite, or a start in the future).
pub fn countdown_left(since: f64, now: f64, lifetime: f64) -> f32 {
let left = 1.0 - (now - since) / lifetime;
if left.is_finite() { left.clamp(0.0, 1.0) as f32 } else { 1.0 }
}
/// The countdown ring's radius around a toast's ×; the × is drawn inside it and its hit target is
/// the ring's square.
fn close_radius() -> f32 {
Tokens::get().space_10
}
/// The points of the countdown ring: an arc of `left` of a turn round `centre`, from twelve o'clock
/// clockwise, so it shrinks back towards twelve o'clock as the time goes. Empty when nothing is left.
pub fn countdown_arc(centre: Pos2, radius: f32, left: f32) -> Vec<Pos2> {
let left = if left.is_finite() { left.clamp(0.0, 1.0) } else { 0.0 };
if left <= 0.0 || !radius.is_finite() || radius <= 0.0 {
return Vec::new();
}
// One segment per 7.5 degrees of the arc, at least one.
let segments = ((48.0 * left).ceil() as usize).max(1);
(0..=segments)
.map(|i| {
let angle = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * left * i as f32 / segments as f32;
centre + radius * vec2(angle.cos(), angle.sin())
})
.collect()
}
/// A toast's close (×) at `rect`, with its countdown ring when `left` is given: a kit-drawn button
/// with an accessible name and hover text (`ui.close`), reachable by Tab and taken with Enter or Space.
fn close_button(ui: &egui::Ui, rect: Rect, id: egui::Id, left: Option<f32>) -> egui::Response {
let t = Tokens::get();
let response = ui.interact(rect, id, Sense::click());
let painter = ui.painter();
let centre = rect.center();
let radius = rect.width().min(rect.height()) * 0.5;
let lit = response.hovered() || response.has_focus();
if lit && ui.is_enabled() {
painter.circle_filled(centre, radius, t.hover);
}
if response.has_focus() {
painter.circle_stroke(centre, radius + t.space_2, Stroke::new(1.0, t.form_primary));
}
if let Some(left) = left {
let arc = countdown_arc(centre, radius - 0.75, left);
if arc.len() >= 2 {
painter.add(egui::Shape::line(arc, Stroke::new(1.5, t.text_dim)));
}
}
let ink = if lit && ui.is_enabled() { t.text } else { t.text_dim };
crate::icons::paint_ink(painter, Rect::from_center_size(centre, vec2(t.space_10, t.space_10)), "close", ink);
let label = solidcraft_ui_text::tr("ui.close");
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
if response.hovered() && ui.is_enabled() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response.backing_on_hover_text(label)
}
/// The toast: its text in a card at the bottom of `free` (`place` says where), led by `lead`, and,
/// below the text, a wrapping row of buttons (`actions`, kit buttons), with what `extras` adds (a
/// close (×) at the top right with its countdown ring, hover texts, a click on the card). Returns
/// the card, which button was clicked, if any, and where each button is (for handles); `None`
/// where `free` has no room for it. The buttons and the × are not live while input is blocked
/// ([`crate::input_blocked`]); none is laid outside the card, whose width is bounded by `free`.
pub fn show(ui: &egui::Ui, free: Rect, place: Place, text: String, lead: Lead, actions: &[(&str, Action)], extras: Extras<'_>) -> Option<Shown> {
// Rects no screen has are rejected too: the corner layout anchors to `free`'s right edge.
if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 || free.max.x.abs().max(free.max.y.abs()) > 1.0e7 {
return None;
}
let t = Tokens::get();
let corner = place == Place::Corner;
// The lead (dot or spinner) is centred in the first `space_14` of a `2 × space_14` column, the
// text follows it and `space_12` closes the right side; `space_8` above and below.
let inset = vec2(2.0 * t.space_14, t.space_8);
let padding = vec2(inset.x + t.space_12, 2.0 * inset.y);
let painter = ui.painter().with_clip_rect(free);
// In the corner every toast is `WIDTH` wide (or the room there is); at the centre each is as
// wide as its text, up to 440 points.
let wrap = if corner { (WIDTH.min(free.width()) - padding.x).max(0.0) } else { (free.width() - padding.x).min(440.0) };
// The × keeps the top-right corner: the text stops short of its ring.
let radius = close_radius();
let close_room = if extras.close.is_some() { 2.0 * radius + t.space_4 + t.space_8 - t.space_12 } else { 0.0 };
let galley = painter.layout(text, crate::type_face::medium(t.font_label), t.text, (wrap - close_room).max(0.0));
// The buttons: each as wide as its text and a margin, in rows no wider than the text may be.
let gap = t.space_8;
let button_height = crate::row_height();
let row_width = if corner { wrap } else { (free.width() - padding.x).clamp(0.0, 440.0) };
// Each button's width as the kit drew it: the same text, font and padding, measured from the
// button itself. Before a button was ever drawn, an estimate stands in for one frame.
let sizes: Vec<(f32, Action)> = actions
.iter()
.map(|(label, kind)| {
let width = button_width(ui.ctx(), label, *kind).unwrap_or_else(|| {
let padding = if *kind == Action::Primary { t.space_16 } else { t.space_12 };
painter.layout_no_wrap((*label).to_string(), crate::type_face::medium(t.font_label), t.text).size().x + 2.0 * padding
});
(width.min(row_width), *kind)
})
.collect();
let spots =
if corner { action_spots_trailing(&sizes, row_width, gap, button_height) } else { action_spots(&sizes, row_width, gap, button_height) };
let widest = spots.iter().map(|spot| spot.right()).fold(0.0, f32::max);
let y = spots.iter().map(|spot| spot.top()).fold(0.0, f32::max);
let actions_height = if spots.is_empty() { 0.0 } else { y + button_height + gap };
let content = vec2((galley.size().x + close_room).max(widest), galley.size().y + actions_height);
let mut size = content + padding;
if corner {
size.x = row_width + padding.x;
}
if extras.close.is_some() {
size.y = size.y.max(2.0 * radius + 12.0);
}
if !size.is_finite() || size.x > free.width() || size.y > free.height() {
return None;
}
let x = if corner { free.right() - size.x * 0.5 } else { free.center().x };
let r = Rect::from_center_size(pos2(x, free.bottom() - size.y * 0.5), size);
if !r.is_finite() {
return None;
}
let frame = Container::new(ui.ctx(), Surface::Toast).frame(ui.ctx(), t.panel, crate::frame::Side::Left);
Container::paint_frame(ui.painter(), frame, r.shrink(frame.stroke.width));
let dot = pos2(r.left() + t.space_14, r.center().y.min(r.top() + inset.y + galley.size().y / 2.0));
match lead {
Lead::Spinner => egui::Spinner::new().size(t.space_12).color(t.text).paint_at(ui, Rect::from_center_size(dot, vec2(t.space_12, t.space_12))),
Lead::Dot(error) => drop(painter.circle_filled(dot, 3.0, if error { t.error } else { t.text })),
}
let text_top = r.min + inset;
painter.galley(text_top, galley.clone(), t.text);
// The card's own click goes first, so its buttons and × (added after it) take theirs. With
// input blocked it is dead too, so a click on the dead × doesn't fall through to it.
let blocked = crate::input_blocked();
let card_clicked = extras.card_hint.is_some_and(|hint| {
let sense = if blocked { Sense::hover() } else { Sense::click() };
ui.interact(r, egui::Id::unique(("sc_toast_card", extras.close.unwrap_or_default())), sense).backing_on_hover_text(hint).clicked()
});
let mut clicked = None;
let mut buttons = Vec::with_capacity(actions.len());
let origin = text_top + vec2(0.0, galley.size().y + gap);
for (index, ((label, kind), spot)) in actions.iter().zip(&spots).enumerate() {
let at = spot.translate(origin.to_vec2());
// A UI of its own on the caller's layer, so the toast needs no `&mut Ui`. It has the
// row's width, so a button is never squeezed into a spot laid out for a smaller one and
// its measured width is its own.
let room = Rect::from_min_size(at.min, vec2(row_width.max(at.width()), at.height()));
let mut button_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::unique(("sc_toast_action", index)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Center)),
);
button_ui.set_clip_rect(button_ui.clip_rect().intersect(r));
let response = button_ui
.add_enabled_ui(!blocked, |ui| match kind {
Action::Primary => crate::primary_button(ui, label),
Action::Secondary => crate::button(ui, *label, crate::Button::Normal),
Action::Dismiss => crate::button(ui, *label, crate::Button::Small),
})
.inner;
let response = match extras.tips.get(index).filter(|tip| !tip.is_empty()) {
Some(tip) => response.backing_on_hover_text(*tip),
None => response,
};
if response.clicked() {
clicked = Some(index);
}
// Remember the width it was drawn at; laid out at another, the toast is laid out again next frame.
let drawn = response.rect.width();
if drawn.is_finite() && drawn > 0.0 {
if sizes.get(index).is_some_and(|(used, _)| (used - drawn).abs() > 0.5 && drawn < row_width) {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|data| data.insert_temp(button_key(label, *kind), drawn));
}
buttons.push(response.rect);
}
// The close (×), top right, level with the text's first line.
let mut close = None;
let mut closed = false;
if let Some(handle) = extras.close {
let line = painter.layout_no_wrap("A".into(), crate::type_face::medium(t.font_label), t.text).size().y;
let centre = pos2(r.right() - t.space_8 - radius, (text_top.y + line * 0.5).max(r.top() + 6.0 + radius));
let at = Rect::from_center_size(centre, vec2(2.0 * radius, 2.0 * radius));
let mut close_ui = egui::Ui::new(
ui.ctx().clone(),
egui::Id::unique(("sc_toast_close_ui", handle)),
egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at),
);
close_ui.set_clip_rect(close_ui.clip_rect().intersect(r));
if blocked {
close_ui.disable();
}
let left = extras.countdown.map(|(left, _)| left);
closed = close_button(&close_ui, at, egui::Id::unique(("sc_toast_close", handle)), left).clicked();
solidcraft_ui_registry::publish_handle(handle, centre);
close = Some(at);
// A frame each time the ring's end moves about a point, not one per display refresh.
if let Some((_, lifetime)) = extras.countdown {
let every = lifetime / (std::f64::consts::TAU * f64::from(radius));
let every = if every.is_finite() { every.clamp(0.05, 1.0) } else { 1.0 };
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(every));
}
}
Some(Shown { rect: r, clicked, buttons, close, closed, card_clicked })
}
#[cfg(test)]
#[path = "toast_tests.rs"]
mod tests;
-133
View File
@@ -1,133 +0,0 @@
use super::*;
use crate::test_support::*;
/// The share of a turn an arc of `points` sweeps clockwise (y down) round `centre`.
fn swept(points: &[Pos2], centre: Pos2) -> f32 {
let turn = std::f32::consts::TAU;
points.windows(2).map(|w| ((w[1] - centre).angle() - (w[0] - centre).angle()).rem_euclid(turn)).sum::<f32>() / turn
}
/// The buttons group by purpose and wrap as whole groups: the main action first, then the
/// alternative, then the dismissals; no button stands alone on a row while its group-mate fits.
#[test]
fn buttons_group_by_purpose_and_wrap_as_groups() {
use Action::{Dismiss, Primary, Secondary};
let four = [(110.0, Primary), (220.0, Secondary), (60.0, Dismiss), (140.0, Dismiss)];
let three = [(110.0, Primary), (60.0, Dismiss), (140.0, Dismiss)];
for card in [260.0_f32, 360.0, 460.0, 560.0, 700.0, 1000.0] {
let row_width = (card - 40.0).min(440.0);
for buttons in [&four[..], &three[..]] {
let spots = action_spots(buttons, row_width, 8.0, 28.0);
let widest = buttons.iter().map(|b| b.0).fold(0.0, f32::max);
for (i, spot) in spots.iter().enumerate() {
assert!(spot.left() >= 0.0 && spot.right() <= row_width.max(widest) + 0.01, "card {card}: {spot:?}");
for other in &spots[i + 1..] {
assert!(!spot.intersects(*other), "card {card}: {spot:?} overlaps {other:?}");
}
}
assert!(spots.iter().all(|s| s.top() > spots[0].top() || s.left() >= spots[0].left()), "card {card}: primary first");
let mut rows: std::collections::BTreeMap<i32, usize> = Default::default();
for spot in &spots {
*rows.entry(spot.top() as i32).or_default() += 1;
}
let group_fits = if buttons.len() == 4 { 110.0 + 8.0 + 220.0 <= row_width } else { 140.0 + 8.0 + 60.0 <= row_width };
if group_fits {
let singles = rows.values().filter(|&&n| n == 1).count();
assert!(singles <= usize::from(buttons.len() == 3), "card {card}, {} buttons: a button is left alone: {rows:?}", buttons.len());
}
if row_width >= 110.0 + 8.0 + 220.0 + 8.0 + 60.0 + 8.0 + 140.0 {
assert_eq!(rows.len(), 1, "card {card}: one row when everything fits");
}
}
}
}
/// In the corner the buttons sit as in the dialogs: right-aligned, dismissals first and the main
/// action right-most; when they wrap, the row with the main action is the bottom one.
#[test]
fn corner_buttons_follow_the_dialogs_and_keep_the_main_action_bottom_right() {
use Action::{Dismiss, Primary, Secondary};
let row_width = WIDTH - 40.0;
let three = [(90.0, Primary), (110.0, Secondary), (50.0, Dismiss)];
let spots = action_spots_trailing(&three, row_width, 8.0, 28.0);
assert!(spots.iter().all(|s| s.top() == 0.0), "one row: {spots:?}");
assert!((spots[0].right() - row_width).abs() < 0.01, "the main action is right-most: {spots:?}");
assert!(spots[2].right() < spots[1].left() && spots[1].right() < spots[0].left(), "dismiss, alternative, main: {spots:?}");
let four = [(90.0, Primary), (190.0, Secondary), (50.0, Dismiss), (120.0, Dismiss)];
let spots = action_spots_trailing(&four, row_width, 8.0, 28.0);
let bottom = spots.iter().map(|s| s.top()).fold(0.0, f32::max);
assert!(bottom > 0.0, "wrapped: {spots:?}");
assert_eq!(spots[0].top(), bottom, "the main action is on the bottom row");
assert!((spots[0].right() - row_width).abs() < 0.01, "and right-most");
for (i, spot) in spots.iter().enumerate() {
assert!(spot.left() >= -0.01 && spot.right() <= row_width + 0.01, "inside the row: {spot:?}");
for other in &spots[i + 1..] {
assert!(!spot.intersects(*other), "{spot:?} overlaps {other:?}");
}
}
for bad in [f32::NAN, f32::INFINITY, -5.0, 0.0] {
assert_eq!(action_spots_trailing(&four, bad, 8.0, 28.0).len(), 4);
}
}
/// The countdown ring around the × is the share of the time left: whole when it starts, half at
/// half time, gone when it is over, and whole for times no clock gives.
#[test]
fn the_countdown_ring_follows_the_time_left() {
assert_eq!(countdown_left(10.0, 10.0, 6.0), 1.0);
assert!((countdown_left(10.0, 13.0, 6.0) - 0.5).abs() < 1e-6);
assert!((countdown_left(0.0, 1.5, 6.0) - 0.75).abs() < 1e-6);
assert_eq!(countdown_left(10.0, 16.0, 6.0), 0.0);
assert_eq!(countdown_left(10.0, 5.0, 6.0), 1.0, "a start in the future");
for (since, now, lifetime) in [(f64::NAN, 1.0, 6.0), (0.0, f64::INFINITY, f64::INFINITY), (0.0, 1.0, 0.0), (0.0, 0.0, 0.0)] {
let left = countdown_left(since, now, lifetime);
assert!((0.0..=1.0).contains(&left), "{since} {now} {lifetime}: {left}");
}
let centre = pos2(100.0, 50.0);
for left in [1.0_f32, 0.75, 0.5, 0.25, 0.01] {
let arc = countdown_arc(centre, 10.0, left);
assert!(arc.len() >= 2, "{left}");
assert!(arc.iter().all(|p| ((*p - centre).length() - 10.0).abs() < 1e-3), "{left}: on the ring");
assert!((arc[0] - pos2(100.0, 40.0)).length() < 1e-3, "starts at twelve o'clock");
assert!((swept(&arc, centre) - left).abs() < 1e-3, "{left}: sweeps {}", swept(&arc, centre));
}
for bad in [0.0_f32, -1.0, f32::NAN] {
assert!(countdown_arc(centre, 10.0, bad).is_empty(), "{bad}");
}
assert!(countdown_arc(centre, f32::NAN, 0.5).is_empty() && countdown_arc(centre, 0.0, 0.5).is_empty());
}
/// The card sits at the bottom of the free part, inside it: centred and as wide as its text, or in
/// the corner `WIDTH` wide against the right; with a × it publishes the ×'s handle. A free part with
/// no room, or none a screen has, draws nothing.
#[test]
fn the_card_sits_at_the_bottom_of_the_free_part() {
install_catalog();
let ctx = context();
let free = Rect::from_min_size(pos2(50.0, 40.0), vec2(800.0, 500.0));
let mut shown = Vec::new();
frame(&ctx, Vec::new(), |ui| {
for place in [Place::Centre, Place::Corner] {
let extras = Extras { close: Some("toast:test:close"), ..Extras::default() };
shown.push(show(ui, free, place, "A short message".into(), Lead::Dot(true), &[("Undo", Action::Secondary)], extras));
}
for bad in
[Rect::NOTHING, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0)), Rect::from_min_size(pos2(f32::NAN, 0.0), vec2(800.0, 500.0))]
{
assert!(show(ui, bad, Place::Centre, "Message".into(), Lead::Spinner, &[], Extras::default()).is_none(), "{bad:?}");
}
});
let (centre, corner) = match shown.as_slice() {
[Some(a), Some(b)] => (a, b),
_ => panic!("both toasts drawn"),
};
for toast in [centre, corner] {
assert!(free.contains_rect(toast.rect), "{:?} inside {free:?}", toast.rect);
assert!((toast.rect.bottom() - free.bottom()).abs() < 0.5, "at the bottom: {:?}", toast.rect);
assert_eq!(toast.buttons.len(), 1);
assert!(toast.close.is_some_and(|x| toast.rect.contains_rect(x)), "the × is on the card");
}
assert!((centre.rect.center().x - free.center().x).abs() < 0.5, "centred: {:?}", centre.rect);
assert!((corner.rect.width() - WIDTH).abs() < 0.5 && (corner.rect.right() - free.right()).abs() < 0.5, "in the corner: {:?}", corner.rect);
assert!(solidcraft_ui_registry::handle_at("toast:test:close").is_some(), "the × publishes its handle");
}
+1 -1
View File
@@ -128,7 +128,7 @@ mapped-surface normal corruption; captured filtering and face precedence remain
authoritative.
The map-free opaque specialization has one private media-stack entry; the
general pipeline retains sixteen. The authored nested-media policy still controls
admission and boundary transitions. Captured scene options select at most three cached programs.
admission and boundary transitions. Captured scene options select at most six cached programs.
The independent progressive device requests six storage buffers per shader
stage. An adapter limited to fewer cannot install this executor; raster and
+1 -1
View File
@@ -425,7 +425,7 @@
{"case":"gen0389","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[37.25,37.25,1272.0]],"n":68},"status":[0.0]}}
{"case":"gen0390","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":[0.0],"status":[0.0]}}
{"case":"gen0391","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[1e+20,1e+20,1.2e+21]],"n":18},"status":[0.0]}}
{"case":"gen0392","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[2e+20,2e+20,4.1e+21]],"n":35},"status":[0.0]}}
{"case":"gen0392","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[3e+20,3e+20,4.8e+21]],"n":35},"status":[0.0]}}
{"case":"gen0393","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[1e+20,1e+20,1.2e+21]],"n":18},"status":[0.0]}}
{"case":"gen0394","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[2.5591573468391324,53.103275897288135,130.71608079606]],"n":38},"status":[0.0]}}
{"case":"gen0395","deferred":null,"error":null,"ints":{},"topology":null,"values":{"record":{"blocks":[[2.0017437499999997,2.00215625,6.02523125]],"n":24},"status":[0.0]}}
+30 -30
View File
@@ -110,7 +110,7 @@
{"case":"gen0066","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.10391917846545073,0.33260368554645753]}}
{"case":"gen0067","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[187.31561203807053,187.31561203807053,2038.5518037638767]],"n":25}}}
{"case":"gen0068","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[14055.437809603614,14136.508394802348,42479.80367892129]],"n":31}}}
{"case":"gen0069","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,8.890176791918215,4.233976762715762]}}
{"case":"gen0069","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,8.355183699130247,0.22718076332719833]}}
{"case":"gen0070","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen0071","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.616509124176106e-15]}}
{"case":"gen0072","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.127535921638985e-15,6.323039971666848]}}
@@ -200,7 +200,7 @@
{"case":"gen0156","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,1.0,1.0,0.0,4.3822007447192055,5.10129929022098,1.0,1.0,1.0,0.0,4.38220074471921,6.19220645536967]}}
{"case":"gen0157","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[37.003304237604205,133.91653031234273,-1833.0365518236415]],"n":150}}}
{"case":"gen0158","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0159","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.033541203184561,1.1388624755787422]}}
{"case":"gen0159","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0160","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[6.655925159050402,"inf","NaN"]],"n":57}}}
{"case":"gen0161","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0162","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[445.59914188673656,482.88538569695436,7444.575471927815]],"n":24}}}
@@ -309,7 +309,7 @@
{"case":"gen0265","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[577.4092515579815,7962.194777833551,-169961.35071960057]],"n":157}}}
{"case":"gen0266","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen0267","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,3.711013974025371,4.400367110004389]}}
{"case":"gen0268","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,95.28207521549169,1.0]}}
{"case":"gen0268","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0269","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.7763568394002505e-15]}}
{"case":"gen0270","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[895.5234375,895.5234375,23001.5390625],[77.27299154995444,1161.17912965113,52791.63378992177],[345.6596118744032,1085.8735447393524,7713.482701718533],[-1615.2030869016355,1675.980608683448,-233728.5731651644],[5884.560911427288,7391.31052065058,656702.2080808633],[-4746.091562328131,6911.409729388134,-822087.083770841],[-3505.774102664856,7418.796888848878,-205460.77027628987],[101.04779448900642,199.2253423461037,481.03984459366734]],"n":1802}}}
{"case":"gen0271","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":36}}}
@@ -460,7 +460,7 @@
{"case":"gen0416","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.8215943042773475,0.0]}}
{"case":"gen0417","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[14023.288430503937,14082.63444244393,41901.022529283786]],"n":26}}}
{"case":"gen0418","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[492.919698939863,3054.888405492601,-40241.59930691832],[-4305.966391886159,8964.503187134795,-346471.63025729696]],"n":486}}}
{"case":"gen0419","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,332.06684015374105,1.0]}}
{"case":"gen0419","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,247.00365512197146,0.7476907770962757]}}
{"case":"gen0420","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0421","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen0422","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":32}}}
@@ -470,7 +470,7 @@
{"case":"gen0426","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[0.7881208978037018,41.87541280813054,-167.0613062851791]],"n":17}}}
{"case":"gen0427","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.29337689212929347,0.47434965767919807]}}
{"case":"gen0428","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,2.563437407172453e-14]}}
{"case":"gen0429","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,22.540316380568978,0.0]}}
{"case":"gen0429","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0430","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.3085938880469226,0.9472143741018448]}}
{"case":"gen0431","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[9.810409299431791,47.37345062389562,107.70148284181238]],"n":17}}}
{"case":"gen0432","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
@@ -519,7 +519,7 @@
{"case":"gen0475","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[25.004057912001684,53.47061380595875,139.34534639501697]],"n":17}}}
{"case":"gen0476","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0477","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0478","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.761903029580106,0.7458088017685005]}}
{"case":"gen0478","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0479","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen0480","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.3554165924989914,0.0]}}
{"case":"gen0481","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":20}}}
@@ -910,7 +910,7 @@
{"case":"gen0866","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0867","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0868","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0869","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,14.77421281094755,1.0]}}
{"case":"gen0869","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0870","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,0.3425121584302618,3.673238107469862,0.0,0.3716603339385631,2.2568454834107543]}}
{"case":"gen0871","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-375.8055250187801,1985.0153483551467,-105927.04324835852],[-386.45104448263277,743.306457284353,-19744.662458294624]],"n":311}}}
{"case":"gen0872","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[454.7647014415332,1019.233585341346,20861.17254157106]],"n":73}}}
@@ -918,7 +918,7 @@
{"case":"gen0874","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0875","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.1415576374290979,0.4648023404937855]}}
{"case":"gen0876","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0877","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,10.649300098095045,1.0]}}
{"case":"gen0877","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0878","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[47.071482928869884,50.33378314777268,305.3077594102937]],"n":17}}}
{"case":"gen0879","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-6.873794556716897,490.21515967088976,-9968.297541784705]],"n":178}}}
{"case":"gen0880","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.8214745442465485,0.4106145750562797]}}
@@ -944,13 +944,13 @@
{"case":"gen0900","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,0.01,0.583829753336207,1.5792307951579345,0.0001,1.0,-0.5257643197977293,0.0,1.0,-2.8973995659781804,0.0001,0.1962406016087629,1.9047421513235459]}}
{"case":"gen0901","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.929642751054232e-14,3.0330513382788067]}}
{"case":"gen0902","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,1.0,1.0,0.0,1.1957467920563633e-15,0.29848414161865333,1.0,1.0,1.0,0.0,2.3979018291927822e-14,0.9273188398591921]}}
{"case":"gen0903","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,5.356230494346432,1.2914264682598677]}}
{"case":"gen0903","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0904","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-198.66667362886096,1193.3231202955014,786.2625726363724]],"n":38}}}
{"case":"gen0905","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[27.939364829351092,73.4767475980321,391.21494748864365]],"n":38}}}
{"case":"gen0906","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen0907","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.4227686813576322,2.07091502692709]}}
{"case":"gen0908","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen0909","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,1.0,1.0,0.0,0.5731439140314799,0.0,1.0,1.0,1.0,0.0,0.5731439140314794,1.0]}}
{"case":"gen0909","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.7316321238328103,0.4633110493839243]}}
{"case":"gen0910","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen0911","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-353.51660588351064,3009.4742407374274,-41487.734651156075]],"n":150}}}
{"case":"gen0912","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":20}}}
@@ -1133,7 +1133,7 @@
{"case":"gen1089","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,3.8218781630188285,0.859272120342625]}}
{"case":"gen1090","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1091","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":30}}}
{"case":"gen1092","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.9538402164155864,0.6442727658993079]}}
{"case":"gen1092","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1093","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[45.86513557903704,63.08935992550412,402.1086116047058]],"n":80}}}
{"case":"gen1094","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":47}}}
{"case":"gen1095","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
@@ -1151,7 +1151,7 @@
{"case":"gen1107","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.0,0.718647628157112]}}
{"case":"gen1108","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1109","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1110","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.3980925297368416,13.726611511845006]}}
{"case":"gen1110","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1111","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1112","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,8.881784197001252e-16]}}
{"case":"gen1113","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.4027603838676003,-1.1426008115087545]}}
@@ -1314,7 +1314,7 @@
{"case":"gen1270","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1271","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen1272","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[58.165979027871884,"inf","NaN"]],"n":43}}}
{"case":"gen1273","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.0652549513768319,0.5390451942605863]}}
{"case":"gen1273","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1274","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.0,2.627559422173876]}}
{"case":"gen1275","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen1276","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":42}}}
@@ -1489,7 +1489,7 @@
{"case":"gen1445","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1446","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-221.2104628451118,2392.659670947097,-105389.5303290618],[-134.59149105967782,1735.2257506206329,-18140.319931791277]],"n":339}}}
{"case":"gen1447","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1448","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,12.29457040830604,-3.042331494768778]}}
{"case":"gen1448","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,7.111682834344441,-3.624362407107757]}}
{"case":"gen1449","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.1633609551356612e-16,3.7453527053496227]}}
{"case":"gen1450","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,3.510833468576701e-16]}}
{"case":"gen1451","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[402.49536151461746,402.49536151461746,5048.001691815796]],"n":17}}}
@@ -1498,7 +1498,7 @@
{"case":"gen1454","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen1455","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1456","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen1457","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,1.0,1.0,0.0,4.343832214161575,0.20373517220503476,1.0,1.0,1.0,0.0,4.379714797750516,1.0]}}
{"case":"gen1457","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.343832214161575,0.20373517220503476]}}
{"case":"gen1458","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,8.881784197001252e-16]}}
{"case":"gen1459","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":20}}}
{"case":"gen1460","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[11.45291111370777,46.47124422081213,9.076353893055959]],"n":17}}}
@@ -1561,7 +1561,7 @@
{"case":"gen1517","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":37}}}
{"case":"gen1518","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[32.7942507998562,48.800109878080086,465.9729480053311]],"n":24}}}
{"case":"gen1519","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[30.23906585012878,77.62834193987624,270.0693861902338]],"n":31}}}
{"case":"gen1520","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,33.78937905650685,2.384908614883602]}}
{"case":"gen1520","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1521","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen1522","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1523","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.9860273225978185e-15]}}
@@ -1589,7 +1589,7 @@
{"case":"gen1545","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.867009842981355,0.0]}}
{"case":"gen1546","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[24.8236299643701,56.80083862610437,139.19820477716308]],"n":31}}}
{"case":"gen1547","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[58.541447488508034,257.1317007185951,2380.1392756021155]],"n":164}}}
{"case":"gen1548","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.758224115322654,0.0]}}
{"case":"gen1548","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,162.22337078032484,0.943284589128605]}}
{"case":"gen1549","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-1.4340532291834407,24.461597869444958,-64.0776584211322]],"n":17}}}
{"case":"gen1550","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.073458360087126,1.0]}}
{"case":"gen1551","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,48.267734020191085,1.0]}}
@@ -1698,7 +1698,7 @@
{"case":"gen1654","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1655","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1656","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1657","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.3802896874236637,0.0]}}
{"case":"gen1657","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,48.896960999198846,0.6769561338341494]}}
{"case":"gen1658","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"out":[2.0,0.0001,1.7700476077911855,0.5,0.0,1.8389436584632106,0.5669566871777503,0.01,1.5110845760232237,0.9794828519489297,0.01,1.5625097078476884,1.0]}}
{"case":"gen1659","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1660","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.19308964133244988,2.630228684789287]}}
@@ -1736,11 +1736,11 @@
{"case":"gen1692","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,1.0,1.0,0.0,0.9927630836692665,3.979554085618298,1.0,1.0,1.0,0.0,0.9927630836692666,6.50792324442017]}}
{"case":"gen1693","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[11.764305787739382,165.19885887499268,-468.45501826624053]],"n":73}}}
{"case":"gen1694","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[14030.711132471508,14185.202188774409,41190.696090877245]],"n":56}}}
{"case":"gen1695","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,39.86963271751308,0.0]}}
{"case":"gen1695","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1696","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.3093727887096073,0.19112229884472792]}}
{"case":"gen1697","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1698","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen1699","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.394182938206742,5.717774132626097]}}
{"case":"gen1699","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,85.58508569409594,5.586763740527126]}}
{"case":"gen1700","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,2.0,25.78956120139028,0.2591011971549998]}}
{"case":"gen1701","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-4.39571124615034,40.158831077071845,-64.21167636718698]],"n":17}}}
{"case":"gen1702","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
@@ -1773,7 +1773,7 @@
{"case":"gen1729","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen1730","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[20.219056562867753,282.68771237809784,-18508.38074774143]],"n":220}}}
{"case":"gen1731","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":42}}}
{"case":"gen1732","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.5611572970102852,4.86141236791868]}}
{"case":"gen1732","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1733","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[956.4101396689789,1162.0948031005598,111602.30155984029]],"n":143}}}
{"case":"gen1734","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[61.31847741716342,203.26244252011506,933.2291267087408]],"n":59}}}
{"case":"gen1735","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e-7,3.4130432523022978,0.8751846331700129,0.01,3.687663794539678,0.32163736333087967]}}
@@ -1913,11 +1913,11 @@
{"case":"gen1869","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1870","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,3.0651072652373186,1.0]}}
{"case":"gen1871","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,2.0,1.5927766740065685,0.7601324912279827]}}
{"case":"gen1872","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,7.354956422976355,0.3850638703472609]}}
{"case":"gen1872","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1873","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.227945714875563,0.589856203221656]}}
{"case":"gen1874","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1875","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1876","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.3914285882969528,-2.632602213219906]}}
{"case":"gen1876","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1877","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"out":[2.0,0.0,1.11648421787197,4.675823022774329,1e-7,1.7460446801420524,4.014552097050354,0.01,-1.5610950444627028,2.468928417816293,1e-7,-0.5657660258837351,-2.227079174144364]}}
{"case":"gen1878","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.1692591912927142,4.043593645374559]}}
{"case":"gen1879","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.743125225995281,0.5536083527833109]}}
@@ -2062,7 +2062,7 @@
{"case":"gen2018","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2019","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[904.7467608361502,2129.8241948470068,94317.80129044075]],"n":94}}}
{"case":"gen2020","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,26.90215023872528,0.9906824478448047]}}
{"case":"gen2021","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,28.857905823233743,1.0]}}
{"case":"gen2021","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2022","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2023","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2024","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.179757028752845,1.0140445883578881]}}
@@ -2167,7 +2167,7 @@
{"case":"gen2123","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[32.74077215960249,48.53000478634035,195.94847608689162]],"n":17}}}
{"case":"gen2124","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.0048591735576161e-14,1.5751224804212827]}}
{"case":"gen2125","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0001,0.5161434448728217,-2.7068676688881967,0.0,-0.5699359688835748,1.5792794966572172]}}
{"case":"gen2126","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,5.556128313677057,2.810003225616906]}}
{"case":"gen2126","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.818720722915652,-1.5798567658339397]}}
{"case":"gen2127","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[-2.409431988614095,22.2967883255553,-87.48916713599624]],"n":17}}}
{"case":"gen2128","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0]}}
{"case":"gen2129","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
@@ -2259,7 +2259,7 @@
{"case":"gen2215","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[35.02072344460194,53.34503656795655,454.9966755992287]],"n":38}}}
{"case":"gen2216","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen2217","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2218","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,3.5683265921762817,3.0261404497837283]}}
{"case":"gen2218","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.8328353550998036,2.478855814392146]}}
{"case":"gen2219","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[14094.207741785098,14148.182399922583,43350.84107037367]],"n":56}}}
{"case":"gen2220","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":62}}}
{"case":"gen2221","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,-1.41481351248475e-13,8.362246947528412]}}
@@ -2333,7 +2333,7 @@
{"case":"gen2289","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2290","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,-4.2341853432510677e-16,5.942808724363556]}}
{"case":"gen2291","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[51.57001352762413,51.57001352762413,694.3942065840137]],"n":24}}}
{"case":"gen2292","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.514280326608742,0.0]}}
{"case":"gen2292","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,10.531529151927153,0.9739776337089641]}}
{"case":"gen2293","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen2294","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen2295","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,2.7113610386943723,4.243871081849651]}}
@@ -2566,7 +2566,7 @@
{"case":"gen2522","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2523","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":{"blocks":[[7.106811364255478,7.106811364255478,49.023072562408395]],"n":19}}}
{"case":"gen2524","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.16452101103224887,0.5161062644162443]}}
{"case":"gen2525","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,11.549815628097758,6.584379542178297]}}
{"case":"gen2525","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2526","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[363.2194097836452,446.6008327416155,42291.28270916905],[8.571849100314921,18.365801669059454,52.02745399800177]],"n":269}}}
{"case":"gen2527","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2528","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,7.776865492603201,0.33333333333288645]}}
@@ -2915,7 +2915,7 @@
{"case":"gen2871","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,3.768354865700267e-13,0.430893535225658]}}
{"case":"gen2872","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.159106867033638e-15]}}
{"case":"gen2873","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[0.1778032562111655,34.79391241317518,-26.671462845858716]],"n":17}}}
{"case":"gen2874","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,42.294050545981115,5.85598046274109]}}
{"case":"gen2874","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,81.76496101361202,0.6992416052561006]}}
{"case":"gen2875","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[1262.8935205534406,2312.9360573753265,172316.6209806222]],"n":164}}}
{"case":"gen2876","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[2760.2370575189366,3064.169832901173,439674.6620118462],[2322.240875342115,6578.267099182435,179130.36453544477],[-2805.767605817838,16121.45237284014,-371706.8205022342],[-482.57706748913176,1356.9998828526695,-9476.778129754588]],"n":808}}}
{"case":"gen2877","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0]}}
@@ -2983,7 +2983,7 @@
{"case":"gen2939","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,4.440892098500626e-16,-2.8224769039536333]}}
{"case":"gen2940","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[["-inf","inf","-inf"]],"n":32}}}
{"case":"gen2941","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2942","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,1.2588335544914846,1.0]}}
{"case":"gen2942","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,0.0,0.24358833893781845,0.12589327433938585]}}
{"case":"gen2943","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen2944","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.2560739669470201e-15]}}
{"case":"gen2945","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
@@ -484,7 +484,7 @@
{"case":"gen0265","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,1.0,1.0,0.0,1.0,0.0,-1.0,1.1874187592087435]}}
{"case":"gen0266","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen0267","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0]}}
{"case":"gen0268","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.000028040806543303308,1.0,1.0,1.0,1.0,5.0,-1.0,0.005295357074202202,-0.004141816524335282]}}
{"case":"gen0268","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.00021981874771429544,0.0]}}
{"case":"gen0269","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,1.0,1.0,0.0,1.0,2.0,-1.0,0.0]}}
{"case":"gen0270","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,2.0,1.0,0.0,2.0,1.0,0.0,0.0,1.0,0.0,2.0,1.0,1.0,1162.0777556633186]}}
{"case":"gen0271","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,1.0,1.0,1.0,1.0,1.0,-1.0,0.0,0.009675157576888329]}}
@@ -539,7 +539,7 @@
{"case":"gen0320","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.2033558454181033,1.0,1.0,0.0,1.0,0.0,-1.0,1.0969757724845628]}}
{"case":"gen0321","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,0.0,1.0,1.0,0.0,2.0,4.0,4.0,0.0]}}
{"case":"gen0322","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,0.0]}}
{"case":"gen0323","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.1854157785231865e-8,1.0,1.0,1.0,1.0,0.0,-1.0,0.00010887680095057838,-0.0003860628760336799]}}
{"case":"gen0323","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e-6,0.0]}}
{"case":"gen0324","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}}
{"case":"gen0325","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,2.0,2.0,3.0,2.0,0.0,1.0,21.74911382256454]}}
{"case":"gen0326","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0]}}
@@ -878,7 +878,7 @@
{"case":"gen0659","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,0.00019812088569604594,0.0]}}
{"case":"gen0660","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,0.044489913269549,0.0]}}
{"case":"gen0661","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0,0.0]}}
{"case":"gen0662","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.000024601260345893353,1.0,1.0,1.0,2.0,1.0,2.0,0.004959965760556554,0.8169537810473053]}}
{"case":"gen0662","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.00017175190806799874,0.0]}}
{"case":"gen0663","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.0,0.0]}}
{"case":"gen0664","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0]}}
{"case":"gen0665","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,31219022411.04517,0.0]}}
@@ -1030,7 +1030,7 @@
{"case":"gen0811","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,1e+20,1.0,1.0,0.0,1.0,2.0,-1.0,0.0]}}
{"case":"gen0812","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,1.0,1.0,1.0,2.0,1.0,2.0,0.0,0.00044850888362399865]}}
{"case":"gen0813","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,0.0]}}
{"case":"gen0814","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.8073337615823752,1.0,1.0,1.0,2.0,3.0,2.0,0.8985175354896393,0.0]}}
{"case":"gen0814","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0]}}
{"case":"gen0815","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,1.0,1.0,0.0,1.0,0.0,-1.0,0.0]}}
{"case":"gen0816","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,2.401188673645111,1.0,1.0,0.0,1.0,1.0,-1.0,1.5495769337613126]}}
{"case":"gen0817","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,1.0,1.0,1.0,2.0,2.0,1.0,0.0,1.8208343374216414]}}
@@ -1081,7 +1081,7 @@
{"case":"gen0862","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":{"blocks":[[441634.7224725167,451873.9273459846,9656606.858450606]],"n":27}}}
{"case":"gen0863","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,2125448.0200684676,0.0]}}
{"case":"gen0864","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen0865","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0024980557906811433,1.0,1.0,1.0,2.0,2.0,1.0,0.04998055412539104,1087.5883334723276]}}
{"case":"gen0865","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1000000.0,0.0]}}
{"case":"gen0866","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0001,0.0]}}
{"case":"gen0867","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen0868","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0]}}
@@ -1262,7 +1262,7 @@
{"case":"gen1043","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0]}}
{"case":"gen1044","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,545144.0224521494,1.0,1.0,2.0,2.0,2.0,3.0,3.410605131648481e-13,616.8360841424507,999.9999999999999]}}
{"case":"gen1045","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.09519795516667759,0.0]}}
{"case":"gen1046","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,7.471149736059766e-7,1.0,1.0,1.0,2.0,2.0,1.0,0.0008643581280962056,1.2448387349950845]}}
{"case":"gen1046","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.2085729482575762e-6,0.0]}}
{"case":"gen1047","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0,0.0]}}
{"case":"gen1048","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0]}}
{"case":"gen1049","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.3090625501006297,1.0,1.0,2.0,2.0,2.0,3.0,2.0014830212433605e-16,-0.1809800360413955,0.9999999999999998]}}
@@ -1320,7 +1320,7 @@
{"case":"gen1101","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.00025758953086876937,0.0]}}
{"case":"gen1102","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0,1.0,1.0,1.0,1.0]}}
{"case":"gen1103","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen1104","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0484074902128923e-6,1.0,1.0,1.0,2.0,1.0,0.0,0.0010239177165245713,0.31088104592728744]}}
{"case":"gen1104","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.2739333677085395e-6,0.0]}}
{"case":"gen1105","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,0.0,0.0]}}
{"case":"gen1106","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.04228126868027234,1.0,1.0,0.0,2.0,4.0,4.0,0.0]}}
{"case":"gen1107","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1771607.9411555228,1.0,1.0,0.0,1.0,0.0,-1.0,1426.2317017560033]}}
@@ -1456,7 +1456,7 @@
{"case":"gen1237","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0]}}
{"case":"gen1238","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen1239","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1.1763824424064344,1.0,1.0,1.0,2.0,4.0,2.0,1.0846116551127571,0.7797439930133148]}}
{"case":"gen1240","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,7.598854197967493,1.0,1.0,1.0,2.0,4.0,6.0,2.7566019295443245,1.2424288894004674]}}
{"case":"gen1240","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,0.0]}}
{"case":"gen1241","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,2.0,1.0,0.0,1.0,1.0,-1.0,0.0,1.0,0.0,1.0,0.0,-1.0,0.0]}}
{"case":"gen1242","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,2.4651903288156624e-32,1.0,1.0,1.0,1.0,1.0,-1.0,1.5700924586837752e-16,0.6323913726970996]}}
{"case":"gen1243","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
@@ -1511,7 +1511,7 @@
{"case":"gen1292","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,1.0,1.0,0.0,1.0,0.0,-1.0,0.0]}}
{"case":"gen1293","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0]}}
{"case":"gen1294","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,10000000000.0,0.0]}}
{"case":"gen1295","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1818931723.4439402,1.0,1.0,1.0,1.0,2.0,-1.0,42648.93578325185,0.34786151559638084]}}
{"case":"gen1295","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,20851990270.78219,0.0]}}
{"case":"gen1296","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1000000.0,0.0]}}
{"case":"gen1297","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,8205094930.34835,2.0,1.0,0.0,1.0,1.0,-1.0,0.000059224469699384165,1.0,0.0,1.0,0.0,-1.0,142325.7218107323]}}
{"case":"gen1298","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,1.0,1.0,0.0,2.0,2.0,0.0,0.0]}}
@@ -1787,7 +1787,7 @@
{"case":"gen1568","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.8277174012645312,1.0,1.0,1.0,1.0,0.0,-1.0,-0.909789756627613,0.9173792555584616]}}
{"case":"gen1569","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen1570","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,3.32699943951523e-8,1.0,1.0,1.0,1.0,0.0,-1.0,0.0001824006425294393,-0.00044976936026900255]}}
{"case":"gen1571","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1539784817.79411,1.0,1.0,1.0,2.0,1.0,2.0,39240.091969745816,72972.50305494145]}}
{"case":"gen1571","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,0.0]}}
{"case":"gen1572","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,10000000000.0]}}
{"case":"gen1573","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,1.0,1.0,0.0,2.0,0.0,0.0,0.0]}}
{"case":"gen1574","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0,1.0,1.0,1.0,1.0]}}
@@ -1945,7 +1945,7 @@
{"case":"gen1726","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1.4395390110589976,1.0,1.0,1.0,2.0,2.0,1.0,1.1998079058995226,0.8766319411459396]}}
{"case":"gen1727","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,0.0]}}
{"case":"gen1728","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen1729","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.1491262296861407,1.0,1.0,1.0,1.0,0.0,-1.0,0.3861686544583088,-0.3714340825229837]}}
{"case":"gen1729","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen1730","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.5017429746676708e-7,0.0]}}
{"case":"gen1731","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.2423058401557787,1.0,1.0,1.0,1.0,0.0,-1.0,4.3008673693763607e-7,0.9999995347862844]}}
{"case":"gen1732","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,0.0]}}
@@ -2174,7 +2174,7 @@
{"case":"gen1955","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e+20,0.0]}}
{"case":"gen1956","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,6.125556143869154e-6,0.0]}}
{"case":"gen1957","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0001860771681095846,0.0]}}
{"case":"gen1958","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.25027174640753,1.0,1.0,1.0,2.0,2.0,4.0,1.1181555108335914,0.0]}}
{"case":"gen1958","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,2.6423967506627553,0.0]}}
{"case":"gen1959","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,1.0,2.0,1.0,0.0,2.0,0.0,0.0,0.0,1.0,0.0,2.0,0.0,1.0,1.0420779333709465]}}
{"case":"gen1960","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen1961","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,0.0]}}
@@ -2328,7 +2328,7 @@
{"case":"gen2109","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1441945.239897416,1.0,1.0,0.0,1.0,0.0,-1.0,0.0]}}
{"case":"gen2110","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.040000000000000015,1.0,1.0,1.0,2.0,1.0,2.0,-0.20000000000000004,1.0]}}
{"case":"gen2111","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,1.0,1.0,1.0,2.0,1.0,2.0,3.3689902020472146e-7,-0.006261654782550835]}}
{"case":"gen2112","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1171989.3945961418,1.0,1.0,1.0,2.0,1.0,2.0,1082.5845900418783,-749.176593571364]}}
{"case":"gen2112","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2113","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,1.0,1.0,0.0,2.0,0.0,0.0,1.7885403901011405]}}
{"case":"gen2114","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"out":[1.0,2.583732972308555,0.0]}}
{"case":"gen2115","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.24512571710033837,1.0,1.0,2.0,2.0,3.0,2.0,0.49510172399249264,1.0,-0.45522123977230156]}}
@@ -2379,7 +2379,7 @@
{"case":"gen2160","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen2161","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,1.0,1.0,0.0,1.0,1.0,-1.0,0.0013720469102941688]}}
{"case":"gen2162","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1e-6,0.0]}}
{"case":"gen2163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,2642766.268721449,1.0,1.0,1.0,2.0,4.0,3.0,1625.6587184035427,-66.66948993610148]}}
{"case":"gen2163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,0.0]}}
{"case":"gen2164","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,0.0]}}
{"case":"gen2165","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,3116412.413466201,1.0,1.0,0.0,1.0,1.0,-1.0,1765.33634570475]}}
{"case":"gen2166","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,0.2701282711073766,1.0,1.0,1.0,1.0,2.0,-1.0,0.5197386565451685,49.43551807770663]}}
@@ -2477,7 +2477,7 @@
{"case":"gen2258","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,1.0,1.0,2.0,2.0,0.0,3.0,8.673617379884035e-19,-0.006142060824531734,0.009999999999999998]}}
{"case":"gen2259","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2260","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0,0.0]}}
{"case":"gen2261","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.03645171249496512,1.0,1.0,1.0,2.0,1.0,2.0,0.19092331574473853,-89908.62862407605]}}
{"case":"gen2261","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,26263279276.56221,0.0]}}
{"case":"gen2262","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0,0.0]}}
{"case":"gen2263","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,0.0,0.0]}}
{"case":"gen2264","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}}
@@ -2503,7 +2503,7 @@
{"case":"gen2284","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}}
{"case":"gen2285","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.1663269709690025,0.0]}}
{"case":"gen2286","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,5.40869860650234e-7,1.0,1.0,1.0,1.0,1.0,-1.0,0.0007354385498804329,0.12630893265007204]}}
{"case":"gen2287","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,348334.87652609206,1.0,1.0,1.0,1.0,2.0,-1.0,590.199014338462,0.28868670333216895]}}
{"case":"gen2287","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,601618.0095664967,0.0]}}
{"case":"gen2288","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,2.111899850617024,1.0,1.0,0.0,2.0,0.0,1.0,0.0]}}
{"case":"gen2289","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,2941609.501100338,0.0]}}
{"case":"gen2290","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.2358221237912574,1.0,1.0,1.0,1.0,0.0,-1.0,0.4856152013593246,0.6146239248171863]}}
@@ -2542,11 +2542,11 @@
{"case":"gen2323","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[4.0,8.549927696785007e-25,1.0,1.0,0.0,1.0,2.0,-1.0,9.246581907269846e-13]}}
{"case":"gen2324","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
{"case":"gen2325","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,1.0,1.0,2.0,2.0,3.0,2.0,0.0,1000.0,-350.96365577431806]}}
{"case":"gen2326","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,3.561912472928586e-8,1.0,1.0,1.0,2.0,5.0,6.0,0.00018873029626767892,0.0]}}
{"case":"gen2326","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1.604803802691189e-7,1.0,1.0,1.0,2.0,4.0,6.0,0.00040060002529845016,0.000029753688884989944]}}
{"case":"gen2327","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,0.0]}}
{"case":"gen2328","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.5561838750265599,0.0]}}
{"case":"gen2329","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,2435737126.010172,1.0,1.0,1.0,1.0,4.0,-1.0,49353.187597258315,1.5532892305942585]}}
{"case":"gen2330","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,797223384.7983719,1.0,1.0,1.0,1.0,1.0,-1.0,28235.144497564943,5516.141220453665]}}
{"case":"gen2330","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,29205572599.001305,0.0]}}
{"case":"gen2331","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,0.6865186631501436,0.0]}}
{"case":"gen2332","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2333","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0]}}
@@ -2724,7 +2724,7 @@
{"case":"gen2505","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1.4998301855576982e-24,1.0,1.0,1.0,2.0,1.0,0.0,1.2246755429736066e-12,3.47684312357685e-9]}}
{"case":"gen2506","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,4.7046377796278434e-6,1.0,1.0,1.0,2.0,6.0,4.0,0.0021690176992426417,1.0]}}
{"case":"gen2507","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,0.0]}}
{"case":"gen2508","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.38469006696309,1.0,1.0,1.0,1.0,0.0,-1.0,0.6202338808571247,0.45904401707623377]}}
{"case":"gen2508","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2509","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,5.7423896066387095e-6,1.0,1.0,1.0,2.0,4.0,6.0,0.0023963283595197695,0.5796480378255998]}}
{"case":"gen2510","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,2.006563597960105,1.0,1.0,0.0,1.0,1.0,-1.0,1.7545336304610437]}}
{"case":"gen2511","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,8.27587240280446e-17,1.0,1.0,1.0,2.0,4.0,2.0,9.097182202640804e-9,0.7132592934180323]}}
@@ -2819,7 +2819,7 @@
{"case":"gen2600","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,1.0,1.0,1.0,1.0,2.0,3.0,4.0,2.478893781024764,0.14726437355710467]}}
{"case":"gen2601","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.641909575201446e-22,1.0,1.0,0.0,2.0,0.0,3.0,1.281370194440875e-11]}}
{"case":"gen2602","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2603","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,7.4697351050123485e-6,1.0,1.0,1.0,1.0,1.0,-1.0,0.0027330816133098457,1.2420837406269365]}}
{"case":"gen2603","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.09254222191209577,1.0,1.0,1.0,1.0,1.0,-1.0,0.30420753099174874,0.3374169941385309]}}
{"case":"gen2604","deferred":null,"error":null,"ints":{"status":[1006]},"topology":null,"values":{"out":[4.0,0.07364765324764488,2.0,1.0,0.0,1.0,3.0,-1.0,8.737299948112228e-10,1.0,0.0,1.0,1.0,-1.0,8.83666254522598e-10]}}
{"case":"gen2605","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,3.4114408850142034e-25,1.0,1.0,1.0,2.0,6.0,3.0,-5.840754133683598e-13,0.11445301595300968]}}
{"case":"gen2606","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,5871907192.781015,0.0]}}
@@ -3034,7 +3034,7 @@
{"case":"gen2815","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,1.0,1.0,1.0,1.0,2.0,-1.0,0.0,0.0]}}
{"case":"gen2816","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[0.0,0.0]}}
{"case":"gen2817","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0,0.0]}}
{"case":"gen2818","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,554482553.4273161,1.0,1.0,1.0,1.0,3.0,-1.0,23547.453225929043,5415.338799531961]}}
{"case":"gen2818","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,4680990460.944897,1.0,1.0,1.0,1.0,5.0,-1.0,68417.76422059476,0.6153887856798073]}}
{"case":"gen2819","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.1162446036609621,1.0,1.0,0.0,1.0,1.0,-1.0,8.887512898903296e-10]}}
{"case":"gen2820","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,1.0,1.0,2.0,2.0,2.0,3.0,0.0,0.25596012811251606,999.9999999999998]}}
{"case":"gen2821","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,0.0,1.0,1.0,0.0,1.0,2.0,-1.0,0.0]}}
@@ -3182,7 +3182,7 @@
{"case":"gen2963","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1e+20,0.0]}}
{"case":"gen2964","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,0.0]}}
{"case":"gen2965","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1600189.5453812296,0.0]}}
{"case":"gen2966","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1915575426.0755055,1.0,1.0,1.0,2.0,2.0,3.0,43767.287168335046,0.08258848128000856]}}
{"case":"gen2966","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,6054848889.732665,0.0]}}
{"case":"gen2967","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[2.0,1e+20,0.0]}}
{"case":"gen2968","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[3.0,0.0001,1.0,1.0,0.0,1.0,1.0,-1.0,4.731201631013364e-12]}}
{"case":"gen2969","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"out":[1.0,1.0,1.0,1.0,1.0,1.0]}}
@@ -1165,10 +1165,10 @@
{"case":"static_operation_matrix_1_14_0_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1007.50007,1007.50007,12090.50028]],"n":20},"status":[0.0]}}
{"case":"static_operation_matrix_1_14_1_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[2013.6500700000001,2013.6500700000001,36224.55028]],"n":32},"status":[0.0]}}
{"case":"static_operation_matrix_1_14_1_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[2013.6500700000001,2013.6500700000001,36224.55028]],"n":32},"status":[0.0]}}
{"case":"static_operation_matrix_1_15_0_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1008.7000700000001,1008.7000700000001,12108.80028]],"n":20},"status":[0.0]}}
{"case":"static_operation_matrix_1_15_0_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1008.7000700000001,1008.7000700000001,12108.80028]],"n":20},"status":[0.0]}}
{"case":"static_operation_matrix_1_15_1_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1008.7000700000001,1008.7000700000001,12108.80028]],"n":20},"status":[0.0]}}
{"case":"static_operation_matrix_1_15_1_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1008.7000700000001,1008.7000700000001,12108.80028]],"n":20},"status":[0.0]}}
{"case":"static_operation_matrix_1_15_0_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[0.0]}}
{"case":"static_operation_matrix_1_15_0_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[0.0]}}
{"case":"static_operation_matrix_1_15_1_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[0.0]}}
{"case":"static_operation_matrix_1_15_1_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[0.0]}}
{"case":"static_operation_matrix_1_16_0_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[1007.0]}}
{"case":"static_operation_matrix_1_16_0_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[1007.0]}}
{"case":"static_operation_matrix_1_16_1_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":[0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.00001,0.0],"status":[1007.0]}}
@@ -1757,17 +1757,9 @@
{"case":"subdivided_1_14_1000_40_30_50_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[6104.00072,6104.00072,583764.02628]],"n":121},"status":[0.0]}}
{"case":"subdivided_1_14_1000_40_30_50_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[6104.00072,6104.00072,583764.02628]],"n":121},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_2_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1017.50025,1017.50025,30535.00325]],"n":38},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_2_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[12156.459903241714,12156.459903241714,1165353.2577324184]],"n":170},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_2_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[12156.459903241714,12156.459903241714,1165353.2577324184]],"n":170},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_50_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1017.50025,1017.50025,30535.00325]],"n":38},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_50_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[12156.459903241714,12156.459903241714,1165353.2577324184]],"n":170},"status":[0.0]}}
{"case":"subdivided_1_14_1_12_11_50_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[12156.459903241714,12156.459903241714,1165353.2577324184]],"n":170},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_2_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1034.00072,1034.00072,79645.52628]],"n":85},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_2_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[15408.354821966252,15408.354821966252,2477488.2402420687],[22571.6040481324,22571.6040481324,2865419.1404500613],[3107.4083269131957,3107.4083269131957,65264.39151900351]],"n":553},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_2_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[15408.354821966252,15408.354821966252,2477488.2402420687],[22571.6040481324,22571.6040481324,2865419.1404500613],[3107.4083269131957,3107.4083269131957,65264.39151900351]],"n":553},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_50_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[1034.00072,1034.00072,79645.52628]],"n":85},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_50_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[15408.354821966252,15408.354821966252,2477488.2402420687],[22571.6040481324,22571.6040481324,2865419.1404500613],[3107.4083269131957,3107.4083269131957,65264.39151900351]],"n":553},"status":[0.0]}}
{"case":"subdivided_1_14_1_40_30_50_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[15408.354821966252,15408.354821966252,2477488.2402420687],[22571.6040481324,22571.6040481324,2865419.1404500613],[3107.4083269131957,3107.4083269131957,65264.39151900351]],"n":553},"status":[0.0]}}
{"case":"subdivided_1_14_1e+06_12_11_2_0","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[501013.50025,501013.50025,16030411.50325]],"n":38},"status":[0.0]}}
{"case":"subdivided_1_14_1e+06_12_11_2_1","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[501013.50025,501013.50025,16030411.50325]],"n":38},"status":[0.0]}}
{"case":"subdivided_1_14_1e+06_12_11_2_3","deferred":null,"error":null,"ints":{},"topology":null,"values":{"static":{"blocks":[[501013.50025,501013.50025,16030411.50325]],"n":38},"status":[0.0]}}
+10
View File
@@ -10,6 +10,16 @@
"gen0150": "tangency-labs/solidcraft#14799: a sphere of radius -3.06e-4 united with a 7.5e-4 x 3.9e-4 x 1.4e-3 box returns the sphere alone (volume 1.20e-10, Level 2 check fails) where the union holds the box (4.28e-10); upstream refused it (1001) because its capture distance spanned both parts",
"ub175_after_editing_imprint_failure": "tangency-labs/solidcraft#14799: a sphere of radius 6.8e-4 united with a 7.8e-4 x 5.4e-4 x 7.2e-4 box reaching outside it returns the sphere alone (volume 1.33e-9, Level 2 check fails); upstream refused it (1001) because its capture distance spanned both parts"
},
"intersect.topology_solver_p2": {
"subdivided_1_14_1_12_11_2_1": "tangency-labs/solidcore#3147: the All projected minimum returns 7 of the 21 answers within tolerance; the 14 missing are line endpoints (t = 0 or 1), leaving 5 of 12 vertices without an answer",
"subdivided_1_14_1_12_11_2_3": "tangency-labs/solidcore#3147: the All projected minimum returns 7 of the 21 answers within tolerance; the 14 missing are line endpoints (t = 0 or 1), leaving 5 of 12 vertices without an answer",
"subdivided_1_14_1_12_11_50_1": "tangency-labs/solidcore#3147: the All projected minimum returns 7 of the 26 answers within tolerance; the 19 missing are line endpoints (t = 0 or 1), leaving 5 of 12 vertices without an answer",
"subdivided_1_14_1_12_11_50_3": "tangency-labs/solidcore#3147: the All projected minimum returns 7 of the 26 answers within tolerance; the 19 missing are line endpoints (t = 0 or 1), leaving 5 of 12 vertices without an answer",
"subdivided_1_14_1_40_30_2_1": "tangency-labs/solidcore#3147: the All projected minimum returns 24 of the 72 answers within tolerance; the 48 missing are line endpoints (t = 0 or 1), leaving 16 of 40 vertices without an answer",
"subdivided_1_14_1_40_30_2_3": "tangency-labs/solidcore#3147: the All projected minimum returns 24 of the 72 answers within tolerance; the 48 missing are line endpoints (t = 0 or 1), leaving 16 of 40 vertices without an answer",
"subdivided_1_14_1_40_30_50_1": "tangency-labs/solidcore#3147: the All projected minimum returns 24 of the 88 answers within tolerance; the 64 missing are line endpoints (t = 0 or 1), leaving 16 of 40 vertices without an answer",
"subdivided_1_14_1_40_30_50_3": "tangency-labs/solidcore#3147: the All projected minimum returns 24 of the 88 answers within tolerance; the 64 missing are line endpoints (t = 0 or 1), leaving 16 of 40 vertices without an answer"
},
"offset.offset_executive_p1": {
"extend_bilinear_huge": "tangency-labs/solidcore#3000: a 5e8 extension of a bilinear sheet extends 3 of 4 sides; the opposite side's diameter is rounded on the whole net and reads as collapsed",
"gen0004": "tangency-labs/solidcore#3079: the first surface is refused naming a size that is itself refused; the second surface is refused naming a size that is itself refused",
+31 -39
View File
@@ -11,8 +11,7 @@
//! - [`fillet_variable`]: one edge rounded with a radius law.
//! - [`edge_faces_along`]: the two faces of an edge with how far each faces a direction, for an
//! application choosing the face a chamfer's first distance is along.
//! - [`tangent_chain`]: the edges a blend of some edges runs on into tangentially, on the
//! kernel's measures.
//! - [`tangent_chain`]: the edges a blend of some edges runs on into tangentially, the kernel's.
//!
//! The kernel's session is driven as the C API's `round_edges` drove SMLib's executive:
//! the turn, parameters, the set-back vertices, the corners (setbacks set, a chamfer's
@@ -55,7 +54,6 @@ use solidcore::{Brep, EntityRef, Kernel, SolidError, Vec3};
use crate::body::{finish, Body, Expect};
use crate::error::status;
use crate::session::{answer, measure_body, with_body};
use crate::tangent::Chain;
use crate::{Error, FitKind, Result, RoomReason, Unblendable};
mod room;
@@ -1434,45 +1432,39 @@ fn faces_along_indices(
})
}
/// The tangent chain of the edges of `body` at record indices `edges` (through `order`, its
/// record's [`crate::Order`], or the body's own edge order): those edges (each once, in
/// order), then every edge that continues one of them within `angle` radians at a shared
/// vertex, and so on
/// until no more do (the walk: [`crate::tangent`]). An edge does not join when it is not
/// between two faces, when it is smooth (its faces meet within `angle` all along it, a seam
/// among them), when it turns over (convex where the edge it continues is concave, or the
/// reverse), or when it is in `passed_over`. An edge whose smoothness the kernel leaves
/// undecided joins nothing and is named in [`Chain::undecided`]. Every measure is the kernel's
/// (`query::tangent_edge`, `query::edge_turn`). For a blend that is to run on: the edges to
/// blend together.
/// The tangent chain of the edges of `body` at record indices `edges` (the kernel's
/// `blend_refusal::tangent_chain`): those edges (each once, in order), then every edge with a
/// blend that one of them runs on into at a vertex, leaving it opposite within `angle`
/// radians, along one of its faces, of its convexity and with faces not tangent there, and so
/// on until no more do. For a blend that is to run on: the edges to blend together.
///
/// # Errors
/// [`Error::Argument`] for no edges, an index past the edges or naming an edge the body does
/// not have, or an angle not finite and strictly between 0 and a right angle; the kernel's
/// refusals (a face with no NURBS form or in power-basis form, an edge use with no pcurve, a
/// pcurve leaving its face's net); [`Error::Cancelled`] when the session's cancel token is
/// cancelled.
pub fn tangent_chain(
body: &Body,
order: Option<&crate::Order>,
edges: &[usize],
passed_over: &[usize],
angle: f64,
) -> Result<Chain> {
let walk = crate::tangent::edge_walk;
match order {
Some(order) => {
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_chain", &copy, |k, b| {
walk(k, b, &mapped.edges, edges, passed_over, angle)
})?
}
None => measure_body("tangent_chain", body, |k, b| {
// the copy keeps the order: an edge's index in it is its index in the body
let list = b.edge_list().to_vec();
walk(k, b, &list, edges, passed_over, angle)
})?,
/// [`Error::Argument`] for no edges, an index past the body's edges, or an angle not finite
/// and strictly between 0 and a right angle; the kernel's refusals.
pub fn tangent_chain(body: &Body, edges: &[usize], angle: f64) -> Result<Vec<usize>> {
if edges.is_empty() || !(angle > 0.0 && angle < std::f64::consts::FRAC_PI_2) {
return Err(Error::Argument);
}
measure_body("tangent_chain", body, |k, b| {
// the copy keeps the order: an edge's index in it is its index in the body
let list = b.edge_list().to_vec();
let mut seeds = Vec::new();
for &i in edges {
match list.get(i) {
Some(&e) => seeds.push(e),
None => return Ok(Err(Error::Argument)),
}
}
let chain = solidcore::blend::blend_refusal::tangent_chain(k, b, &seeds, angle)?;
let mut out = Vec::new();
for e in chain {
match list.iter().position(|&x| x == e) {
Some(i) => out.push(i),
None => return Ok(Err(Error::Exception)),
}
}
Ok(Ok(out))
})?
}
/// The edges of `body` at record indices `edges` chamfered `distance` back along the face of
+4 -427
View File
@@ -1,24 +1,15 @@
//! Free curves: a B-spline from the control points a designer places, one of its control points
//! moved, the curve under an affine map, the curve seen in two views, and its curvature comb.
//! The curve is the record's ([`Curve`]); the knots, the map's carrying of the control points,
//! the views' meeting and the curvature are the kernel's
//! ([`solidcore::nurbs::knots::clamped_uniform`],
//! [`solidcore::nurbs::crv_basic::Curve::transform`],
//! [`solidcore::intersect::views::curves_from_views`],
//! moved, and its curvature comb. The curve is the record's ([`Curve`]); the knots and the
//! curvature are the kernel's ([`solidcore::nurbs::knots::clamped_uniform`],
//! [`solidcore::query::continuity::curve_comb`]). Ops checks the inputs and converts.
use solidcore::curve::from_parameters::from_parameters;
use solidcore::geom::certified::Verdict;
use solidcore::intersect::views::{curves_from_views, views_do_not_meet};
use solidcore::nurbs::knots::{clamped_uniform, MAX_DEGREE};
use solidcore::nurbs::math::{MatrixType, RealMatrix};
use solidcore::query::continuity;
use solidcore::{SolidError, Vec3};
use solidcore_record::brep::{Curve, Invalid};
use solidcore::SolidError;
use solidcore_record::brep::Curve;
use crate::body::REGULAR_RATIO;
use crate::continuity::{tooth, CombTooth, MAX_COMB_TEETH, MIN_COMB_TEETH};
use crate::curve_knots::{net_of, record_of};
use crate::session::run;
use crate::sweep::{coord_ok, CURVE_MAX_POINTS};
use crate::{Error, Result};
@@ -94,85 +85,6 @@ fn periodic_twin(knots: &[f64], points: &[[f64; 3]], index: usize) -> Option<usi
}
}
/// `curve` under the affine `matrix`, as [`Body::transform`](crate::Body::transform) takes it:
/// rows 0-2 the images of x, y and z, row 3 the translation (`p' = p·A + t`), the fourth column
/// `(0, 0, 0, 1)`. Every control point is carried by the map
/// ([`solidcore::nurbs::crv_basic::Curve::transform`], `N_CrvTransform`); the degree, the knots
/// and the weights stay as they were. An affine map
/// commutes with the convex combinations a NURBS curve is made of, so the result is the image of
/// the curve itself, to the rounding of each carried point (none for a map whose entries are 0
/// and ±1 on a spline's points). Reflections, non-uniform scales and shears are maps like any
/// other. A spline or a rational spline, clamped or closed (a closed curve's repeated points are
/// carried alike, so it stays closed).
///
/// # Errors
/// [`Error::Argument`], before the curve is read, for a matrix the kernel does not prove regular
/// ([`solidcore::Transform::is_regular`] at [`REGULAR_RATIO`]: an entry not finite, a fourth
/// column that is not `(0, 0, 0, 1)`, a smallest scale not proved above the ratio of the
/// largest); [`Error::Argument`] for another kind of curve, one the record refuses, or an image
/// the record refuses (a coordinate carried past what a double holds); the kernel's status
/// otherwise.
pub fn transform(curve: &Curve, matrix: &[[f64; 4]; 4]) -> Result<Curve> {
let map = solidcore::Transform { m: *matrix };
if map.is_regular(REGULAR_RATIO) != Verdict::Yes {
return Err(Error::Argument);
}
let mut nurb = net_of(curve)?;
// The kernel's matrix acts on a column (`x' = r00 x + r01 y + r02 z + r03`): the record's
// row-vector matrix, its entries moved to their transposed places.
let [[a00, a01, a02, a03], [a10, a11, a12, a13], [a20, a21, a22, a23], [a30, a31, a32, a33]] =
*matrix;
let rows = vec![
vec![a00, a10, a20, a30],
vec![a01, a11, a21, a31],
vec![a02, a12, a22, a32],
vec![a03, a13, a23, a33],
];
run("curve::transform", |_| {
let columns = RealMatrix::from_rows(3, 3, rows, MatrixType::Full, 3)?;
nurb.transform(&columns)?;
Ok(record_of(&nurb))
})?
}
/// The 3D curves seen as `a` along `a_dir` and as `b` along `b_dir` (a side view and a plan
/// view, say): where `a` extruded both ways along `a_dir` meets `b` extruded along `b_dir`, far
/// enough that none of it is left out ([`curves_from_views`], whose docs give the bound). Each
/// curve is a spline within the kernel's default approximation tolerance of both extrusions.
/// The directions need not be unit vectors; `a` and `b` are splines or rational splines, open or
/// closed.
///
/// # Errors
/// [`Error::Argument`] for a direction not finite or past the coordinate bound, of no length,
/// or (within the kernel's angular zero) parallel to the other, or a curve that is not a spline
/// the record takes; [`Error::Empty`] when the views do not meet; [`Error::Record`] for a
/// meeting curve the record cannot hold; the kernel's status otherwise.
pub fn two_view(a: &Curve, a_dir: [f64; 3], b: &Curve, b_dir: [f64; 3]) -> Result<Vec<Curve>> {
if !coord_ok(&a_dir) || !coord_ok(&b_dir) {
return Err(Error::Argument);
}
let (a, b) = (net_of(a)?, net_of(b)?);
let ([ax, ay, az], [bx, by, bz]) = (a_dir, b_dir);
run("curve::two_view", |k| {
let curves =
match curves_from_views(k, &a, Vec3::new(ax, ay, az), &b, Vec3::new(bx, by, bz)) {
Ok(curves) => curves,
Err(SolidError::InvalidInput { .. }) => return Ok(Err(Error::Argument)),
Err(e) if views_do_not_meet(&e) => return Ok(Err(Error::Empty)),
Err(e) => return Err(e),
};
let mut records = Vec::new();
for c in &curves {
let made = solidcore::curve::from_parameters::record_of(k, c, &c.natural_interval())?;
match solidcore_convert::record_curve(made.curve) {
Ok(record) => records.push(record),
Err(Invalid(why)) => return Ok(Err(Error::Record(why.to_owned()))),
}
}
Ok(Ok(records))
})?
}
/// The curvature comb of `curve` over `interval` of its parameter: the kernel's curvature vector
/// at `teeth` even steps, both ends included. Stations where the curve has no tangent are left
/// out.
@@ -345,341 +257,6 @@ mod tests {
assert_eq!(points[4], SQUARE[0]);
}
/// The matrix of the reflection about the plane y = 0.
const MIRROR_Y: [[f64; 4]; 4] = [
[1.0, 0.0, 0.0, 0.0],
[0.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
#[test]
fn a_cubic_reflected_about_y_0_has_its_cvs_mirrored_exactly() {
let wave = [
[0.0, 1.0, 0.5],
[2.0, 3.0, -1.0],
[4.0, -2.0, 0.25],
[6.0, 0.5, 2.0],
[8.0, 4.0, 0.0],
];
let c = cv_curve(&wave, 3).unwrap();
let image = transform(&c, &MIRROR_Y).unwrap();
let (
Curve::Spline { knots, points },
Curve::Spline {
knots: k1,
points: p1,
},
) = (&c, &image)
else {
panic!("a spline: {image:?}")
};
assert_eq!(k1, knots);
let mirrored: Vec<[f64; 3]> = points.iter().map(|&[x, y, z]| [x, -y, z]).collect();
assert_eq!(p1, &mirrored);
// Twice is the curve again.
assert_eq!(transform(&image, &MIRROR_Y).unwrap(), c);
}
#[test]
fn a_rational_curve_keeps_its_weights_and_knots() {
// A quarter circle of radius 10 about the origin, as a rational quadratic.
let w = std::f64::consts::FRAC_1_SQRT_2;
let arc = Curve::RationalSpline {
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
homogeneous: vec![
[10.0, 0.0, 0.0, 1.0],
[10.0 * w, 10.0 * w, 0.0, w],
[0.0, 10.0, 0.0, 1.0],
],
};
let image = transform(&arc, &MIRROR_Y).unwrap();
let (
Curve::RationalSpline { knots, homogeneous },
Curve::RationalSpline {
knots: k1,
homogeneous: h1,
},
) = (&arc, &image)
else {
panic!("a rational spline: {image:?}")
};
assert_eq!(k1, knots);
for (p, q) in homogeneous.iter().zip(h1) {
assert_eq!(q[3], p[3], "the weight");
let mirrored = [p[0], -p[1], p[2]];
assert!(
q.iter().zip(mirrored).all(|(a, b)| (a - b).abs() < 1e-12),
"{q:?} against {mirrored:?}"
);
}
}
#[test]
fn a_motion_takes_rows_as_the_axes_images_and_row_3_as_the_move() {
// A quarter turn about z (x to y, y to -x), then a move by (1, 2, 3): the convention of
// `Body::transform`.
let turn = [
[0.0, 1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[1.0, 2.0, 3.0, 1.0],
];
let image = transform(&cv_curve(&SQUARE, 3).unwrap(), &turn).unwrap();
let Curve::Spline { points, .. } = &image else {
panic!("a spline: {image:?}")
};
let moved: Vec<[f64; 3]> = SQUARE
.iter()
.map(|&[x, y, z]| [1.0 - y, 2.0 + x, 3.0 + z])
.collect();
assert_eq!(points, &moved);
}
#[test]
fn a_closed_curve_stays_closed() {
let c = crate::curve_fit::closed_cv_curve(&SQUARE, 3).unwrap();
let image = transform(&c, &MIRROR_Y).unwrap();
let (Curve::Spline { knots, .. }, Curve::Spline { knots: k1, points }) = (&c, &image)
else {
panic!("a spline: {image:?}")
};
assert_eq!(k1, knots);
// points 0, 1 and 2 are repeated as 4, 5 and 6
for (p, copy) in points.iter().zip(&points[4..]) {
assert_eq!(p, copy);
}
assert_eq!(points[1], [10.0, 0.0, 0.0]);
}
#[test]
fn a_singular_matrix_or_another_curve_is_refused() {
let c = cv_curve(&SQUARE, 3).unwrap();
let mut flat = MIRROR_Y;
flat[1][1] = 0.0;
assert_eq!(transform(&c, &flat), Err(Error::Argument));
let mut projective = MIRROR_Y;
projective[0][3] = 1.0;
assert_eq!(transform(&c, &projective), Err(Error::Argument));
let mut nan = MIRROR_Y;
nan[3][0] = f64::NAN;
assert_eq!(transform(&c, &nan), Err(Error::Argument));
let line = Curve::Line {
start: [0.0; 3],
end: [1.0; 3],
};
assert_eq!(transform(&line, &MIRROR_Y), Err(Error::Argument));
}
/// The kernel's default approximation tolerance of a traced curve: half its zone tolerance
/// of 1e-5.
const APPROX_TOL: f64 = 5e-6;
/// The side view, z = f(x) in the plane y = 0: a cubic Bézier curve whose control points
/// are evenly spaced in x, so x = 9 t; its slope |dz/dx| at most 1 (its polygon's).
fn side() -> Curve {
cv_curve(
&[
[0.0, 0.0, 0.0],
[3.0, 0.0, 2.0],
[6.0, 0.0, -1.0],
[9.0, 0.0, 1.0],
],
3,
)
.unwrap()
}
/// The plan view, y = g(x) in the plane z = 0, x = shift - 3 + 15 t; |dy/dx| at most 0.6.
fn plan(shift: f64) -> Curve {
cv_curve(
&[
[shift - 3.0, 1.0, 0.0],
[shift + 2.0, 4.0, 0.0],
[shift + 7.0, 2.0, 0.0],
[shift + 12.0, 5.0, 0.0],
],
3,
)
.unwrap()
}
/// The point of the spline `c` at `t`, by the kernel's evaluator.
fn at(c: &Curve, t: f64) -> [f64; 3] {
let p = net_of(c)
.unwrap()
.eval(t, solidcore::nurbs::knots::Side::Left)
.unwrap();
[p.x, p.y, p.z]
}
#[test]
fn a_side_and_a_plan_view_give_the_curve_on_both_extrusions() {
let (a, b) = (side(), plan(0.0));
let curves = two_view(&a, [0.0, 1.0, 0.0], &b, [0.0, 0.0, 2.0]).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (Curve::Spline { knots, .. } | Curve::RationalSpline { knots, .. }) = c else {
panic!("a spline: {c:?}")
};
let (t0, t1) = (knots[0], knots[knots.len() - 1]);
for i in 0..=64 {
let x = at(c, t0 + (t1 - t0) * f64::from(i) / 64.0);
assert!(
(-APPROX_TOL..=9.0 + APPROX_TOL).contains(&x[0]),
"past the side view: {x:?}"
);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// On the side view's extrusion along y: a gap measured along z is at most the
// distance times sqrt(1 + slope²), below 1 + slope, the slope at most 1.
let on_a = at(&a, (x[0] / 9.0).clamp(0.0, 1.0));
assert!(
(on_a[2] - x[2]).abs() <= 2.0 * APPROX_TOL,
"{x:?} against {on_a:?}"
);
// On the plan view's extrusion along z, the slope at most 0.6.
let on_b = at(&b, (x[0] + 3.0) / 15.0);
assert!(
(on_b[1] - x[1]).abs() <= 1.6 * APPROX_TOL,
"{x:?} against {on_b:?}"
);
}
}
// The whole of the side view's run is seen.
assert!(
lo <= APPROX_TOL && hi >= 9.0 - APPROX_TOL,
"from {lo} to {hi}"
);
}
/// A side view in y = 0 (seen along y) and a plan view in z = 0 (seen along z) at model
/// scale, about 170 mm across: quadratic Bézier curves, both increasing in x.
const SIDE_Q: [[f64; 3]; 3] = [
[-92.06693847750134, 0.0, -39.76423812596792],
[0.0, 0.0, 18.451591211372346],
[73.55298836314594, 0.0, 15.024361734430357],
];
const PLAN_Q: [[f64; 3]; 3] = [
[-16.758544054811637, -82.27159357644084, 0.0],
[42.459806193714414, -83.42331426400659, 0.0],
[76.37870944288886, -34.86770213654586, 0.0],
];
/// The point of the quadratic Bézier curve `q` at `t` and its derivative, in Bernstein's
/// form (none of the kernel's evaluators).
fn bernstein(q: &[[f64; 3]; 3], t: f64) -> ([f64; 3], [f64; 3]) {
let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
(
[0, 1, 2].map(|i| b0 * q[0][i] + b1 * q[1][i] + b2 * q[2][i]),
[0, 1, 2]
.map(|i| 2.0 * (1.0 - t) * (q[1][i] - q[0][i]) + 2.0 * t * (q[2][i] - q[1][i])),
)
}
/// The parameter of `q` (increasing in x) where its x is `x`, by bisection on [0, 1].
fn at_x(q: &[[f64; 3]; 3], x: f64) -> f64 {
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..100 {
let m = 0.5 * (lo + hi);
if bernstein(q, m).0[0] < x {
lo = m;
} else {
hi = m;
}
}
0.5 * (lo + hi)
}
/// The point at `t` of the record spline with `knots` and homogeneous points `pw`, by
/// de Boor's algorithm (none of the kernel's evaluators).
fn de_boor(knots: &[f64], pw: &[[f64; 4]], t: f64) -> [f64; 3] {
let n = pw.len();
let p = knots.len() - n - 1;
let k = (p..n).rev().find(|&k| knots[k] <= t).unwrap_or(p);
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| pw[j + k - p]).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let (lo, hi) = (knots[j + k - p], knots[j + 1 + k - r]);
let alpha = (t - lo) / (hi - lo);
d[j] = [0, 1, 2, 3].map(|i| (1.0 - alpha) * d[j - 1][i] + alpha * d[j][i]);
}
}
let h = d[p];
[h[0] / h[3], h[1] / h[3], h[2] / h[3]]
}
#[test]
fn views_at_model_scale_give_the_curve_within_the_tolerance() {
let side = cv_curve(&SIDE_Q, 2).unwrap();
let plan = cv_curve(&PLAN_Q, 2).unwrap();
let curves = two_view(&side, [0.0, 1.0, 0.0], &plan, [0.0, 0.0, 1.0]).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (knots, pw): (&[f64], Vec<[f64; 4]>) = match c {
Curve::Spline { knots, points } => (
knots,
points.iter().map(|&[x, y, z]| [x, y, z, 1.0]).collect(),
),
Curve::RationalSpline { knots, homogeneous } => (knots, homogeneous.clone()),
_ => panic!("a spline: {c:?}"),
};
let p = knots.len() - pw.len() - 1;
let (t0, t1) = (knots[p], knots[pw.len()]);
for i in 0..=512 {
let x = de_boor(knots, &pw, t0 + (t1 - t0) * f64::from(i) / 512.0);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// A gap along the view's other axis is at most the distance times
// sqrt(1 + slope²), below 1 + slope; twice the tolerance, as the intersector
// checks its spans at interior samples.
let (q, d) = bernstein(&SIDE_Q, at_x(&SIDE_Q, x[0]));
let allowed = 2.0 * APPROX_TOL * (1.0 + (d[2] / d[0]).abs());
assert!(
(q[2] - x[2]).abs() <= allowed,
"off the side view: {x:?} against {q:?}"
);
let (q, d) = bernstein(&PLAN_Q, at_x(&PLAN_Q, x[0]));
let allowed = 2.0 * APPROX_TOL * (1.0 + (d[1] / d[0]).abs());
assert!(
(q[1] - x[1]).abs() <= allowed,
"off the plan view: {x:?} against {q:?}"
);
}
}
// The overlap of the two runs is seen: from the plan view's start to the side view's end.
assert!(
(lo - PLAN_Q[0][0]).abs() <= 2.0 * APPROX_TOL
&& (hi - SIDE_Q[2][0]).abs() <= 2.0 * APPROX_TOL,
"from {lo} to {hi}"
);
}
#[test]
fn parallel_directions_are_refused_and_views_apart_are_empty() {
let (a, b) = (side(), plan(0.0));
let y = [0.0, 1.0, 0.0];
assert_eq!(two_view(&a, y, &b, [0.0, -2.0, 0.0]), Err(Error::Argument));
assert_eq!(two_view(&a, y, &b, [0.0; 3]), Err(Error::Argument));
assert_eq!(
two_view(&a, y, &b, [f64::NAN, 0.0, 1.0]),
Err(Error::Argument)
);
let line = Curve::Line {
start: [0.0; 3],
end: [1.0; 3],
};
assert_eq!(
two_view(&line, y, &b, [0.0, 0.0, 1.0]),
Err(Error::Argument)
);
// The plan view moved to x in [20, 35], past the side view's [0, 9].
assert_eq!(
two_view(&a, y, &plan(23.0), [0.0, 0.0, 1.0]),
Err(Error::Empty)
);
}
#[test]
fn an_arcs_comb_points_at_its_centre_with_the_inverse_radius() {
// A quarter circle of radius 10 about (1, 2, 3) in its z plane, as a rational quadratic.
+6 -18
View File
@@ -131,24 +131,6 @@ fn within(
/// # Errors
/// [`Error::Argument`] for another curve, one the record refuses, or knots not clamped.
pub(crate) fn nurb_of(curve: &Curve) -> Result<NurbCurve> {
let nurb = net_of(curve)?;
let (knots, degree) = (nurb.knots(), nurb.degree());
let flat =
|ends: Option<&[f64]>| ends.is_some_and(|e| e.windows(2).all(|w| w.first() == w.get(1)));
let last = knots.len().checked_sub(degree + 1);
if !flat(knots.get(..=degree)) || !flat(last.and_then(|s| knots.get(s..))) {
return Err(Error::Argument);
}
Ok(nurb)
}
/// The kernel's curve of a record spline or rational spline that is valid, on any knots the
/// record takes (clamped, or a closed curve's periodic ones): its control points and their
/// weights, its degree and knots as they are.
///
/// # Errors
/// [`Error::Argument`] for another curve, or one the record refuses.
pub(crate) fn net_of(curve: &Curve) -> Result<NurbCurve> {
check_curve(curve, &mut Budget::input()).map_err(|_| Error::Argument)?;
let (knots, pw): (&[f64], Vec<CPoint>) = match curve {
Curve::Spline { knots, points } => (
@@ -171,6 +153,12 @@ pub(crate) fn net_of(curve: &Curve) -> Result<NurbCurve> {
.len()
.checked_sub(pw.len() + 1)
.ok_or(Error::Argument)?;
let flat =
|ends: Option<&[f64]>| ends.is_some_and(|e| e.windows(2).all(|w| w.first() == w.get(1)));
let last = knots.len().checked_sub(degree + 1);
if !flat(knots.get(..=degree)) || !flat(last.and_then(|s| knots.get(s..))) {
return Err(Error::Argument);
}
NurbCurve::new(pw, degree, knots.to_vec()).map_err(|_| Error::Argument)
}
+2 -7
View File
@@ -123,9 +123,7 @@ fn the_raked_wedges_rounds_keep_their_definitions() {
seeds.push(edge_through(&b, p));
}
}
let chain = crate::blend::tangent_chain(&b, None, &seeds, &[], 0.5_f64.to_radians()).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
let chain = chain.members;
let chain = crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap();
blend(&b, Blend::Round, &chain, &[0.854], &[], Corners::Filled).unwrap();
let report = b.check(CheckLevel::All).unwrap();
assert!(report.is_valid(), "{}", report.text);
@@ -169,10 +167,7 @@ fn exact_wedge_rounds() -> (usize, usize) {
let round = |pts: &[[f64; 3]], r: f64, chain: bool| {
let seeds: Vec<usize> = pts.iter().map(|&p| edge_through(&b, p)).collect();
let edges = if chain {
let chain =
crate::blend::tangent_chain(&b, None, &seeds, &[], 0.5_f64.to_radians()).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
chain.members
crate::blend::tangent_chain(&b, &seeds, 0.5_f64.to_radians()).unwrap()
} else {
seeds
};
-1
View File
@@ -72,7 +72,6 @@ use crate::{Error, Result};
type Out<T> = std::result::Result<T, Error>;
pub mod birail;
pub mod controlled;
pub mod railed;
mod record;
-388
View File
@@ -1,388 +0,0 @@
//! The controlled sweep: a profile swept along a path of lines, arcs and helices by laws
//! (orientation, twist, end scale, up to two guide rails), skinned through the kernel's
//! stations ([`solidcore::sweep::stations`]), and which faces it made from which profile curve
//! and which are its caps, from the kernel's skin rather than any geometry.
//!
//! Every number is the kernel's: where the stations go ([`stations::fractions`], SolidCraft's
//! fit convention), each station's frame and the skin. The skin is a cubic interpolation through
//! the stations; its distance from the law surface between them is not certified (exact where
//! the sections vary linearly along a straight path, as an end scale along a line does;
//! #13637).
//!
//! Refused, by name: a path with corners (#13639) and a closed path
//! (#13638), each its own construction.
use solidcore::kernel::Kernel;
use solidcore::math::Vec3;
use solidcore::sweep::api_surfaces::{create_skin_primitive_with_sources, NotTraced, SkinSources};
use solidcore::sweep::stations::{
self, Laws, Orientation as KOrientation, Path, Piece as KPiece, RailFit as KRailFit, Stop,
};
use solidcore::topology::{Brep, FaceId};
use super::{bspline, parse, Affine, Curve, KCurve, Made};
use crate::body::{finish, Body, Expect};
use crate::error::status;
use crate::session::run;
use crate::{Error, Result};
/// SolidCraft's limits on a controlled sweep's laws: conventions of the app, not geometry
/// (the kernel refuses only what has no sweep; `solidcore::sweep::stations` module docs).
/// The most twist, four turns (radians, `8 pi`).
pub const MAX_TWIST: f64 = 25.132_741_228_718_345;
/// The smallest end scale (SolidCraft's).
pub const MIN_SCALE: f64 = 0.01;
/// The largest end scale (SolidCraft's).
pub const MAX_SCALE: f64 = 100.0;
/// The least angle a path meets a parallel profile's plane at (radians, 5 degrees;
/// SolidCraft's).
pub const MIN_PARALLEL_CROSSING: f64 = 0.087_266_462_599_716_47;
/// The least sine of the angle between the path and a direction orientation (SolidCraft's).
pub const MIN_DIRECTION_ACROSS: f64 = 1e-3;
/// One piece of a controlled sweep's path.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Piece {
/// From `a` to `b`.
Line {
/// Its start.
a: [f64; 3],
/// Its end.
b: [f64; 3],
},
/// From `start`, `angle` radians right-handed about the unit `axis` through `centre`.
Arc {
/// A point of the axis.
centre: [f64; 3],
/// The axis (unit).
axis: [f64; 3],
/// Its start.
start: [f64; 3],
/// The angle run (radians, positive).
angle: f64,
},
/// As [`Piece::Arc`], rising `rise` along the axis per radian.
Helix {
/// A point of the axis.
centre: [f64; 3],
/// The axis (unit).
axis: [f64; 3],
/// Its start.
start: [f64; 3],
/// The angle run (radians, positive).
angle: f64,
/// The rise per radian.
rise: f64,
},
}
/// How the profile turns as it follows the path ([`solidcore::sweep::stations::Orientation`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum Orientation {
/// Square to the path, carried by its rotation-minimizing frame.
#[default]
Perpendicular,
/// As drawn.
Parallel,
/// Square to the path, its turn fixed by a direction.
Direction([f64; 3]),
}
/// How guide rails size the profile.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RailFit {
/// Evenly.
#[default]
Scale,
/// Only across to the rail.
Stretch,
}
/// A controlled sweep's laws. The default is a plain sweep.
#[derive(Clone, Debug, PartialEq)]
pub struct ControlledSweep {
/// How the profile turns.
pub orientation: Orientation,
/// The twist over the path (radians).
pub twist: f64,
/// The profile's scale at the end.
pub end_scale: f64,
/// Up to two guide rails, each one curve.
pub rails: Vec<Curve>,
/// How the rails size the profile.
pub rail_fit: RailFit,
}
impl Default for ControlledSweep {
fn default() -> Self {
ControlledSweep {
orientation: Orientation::Perpendicular,
twist: 0.0,
end_scale: 1.0,
rails: Vec::new(),
rail_fit: RailFit::Scale,
}
}
}
/// Which faces of a controlled sweep came from where, in the body's record indices.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SweptFaces {
/// The start cap's faces.
pub start: Vec<usize>,
/// The end cap's faces.
pub end: Vec<usize>,
/// Each wall face, in the skin's order, with the index (into the profile) of the curve it
/// was swept from, as the kernel's skin traced it (`create_skin_primitive_with_sources`).
/// A curve with a kink of its own sweeps a wall on each side of it.
pub walls: Vec<(usize, usize)>,
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
fn p3(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
fn kernel_piece(p: &Piece) -> KPiece {
match *p {
Piece::Line { a, b } => KPiece::Line { a: v3(a), b: v3(b) },
Piece::Arc {
centre,
axis,
start,
angle,
} => KPiece::Arc {
centre: v3(centre),
axis: v3(axis),
start: v3(start),
angle,
},
Piece::Helix {
centre,
axis,
start,
angle,
rise,
} => KPiece::Helix {
centre: v3(centre),
axis: v3(axis),
start: v3(start),
angle,
rise,
},
}
}
/// The body's record index of each of `ids`.
fn indices(order: &[FaceId], ids: &[FaceId]) -> Option<Vec<usize>> {
ids.iter()
.map(|f| order.iter().position(|g| g == f))
.collect()
}
/// The profile `profile` (closed loops in the plane whose normal is `profile_normal`) swept
/// along `path` by `laws`, capped at both ends, and which faces came from where.
///
/// # Errors
/// [`Error::Argument`] for no path pieces, a profile or rail the curves refuse, or more than
/// two rails; the kernel's refusal ([`Error::Status`]) of a twist or end scale past
/// SolidCraft's limits ([`MAX_TWIST`], [`MIN_SCALE`], [`MAX_SCALE`], passed to it);
/// [`Error::Unsupported`] for a path with corners or a closed path, and for a skin the kernel
/// does not trace to its profile curves (a controlled sweep's sections are affine images of
/// one profile, so it always is: `create_skin_primitive_with_sources`) or a wall not swept
/// from one curve of each section; the kernel's refusals of the path, the laws and
/// the rails ([`Error::Status`]), with SolidCraft's least crossing angle and sine
/// ([`MIN_PARALLEL_CROSSING`], [`MIN_DIRECTION_ACROSS`]); those of `finish`.
pub fn sweep_controlled(
profile: &[Curve],
profile_normal: [f64; 3],
path: &[Piece],
laws: &ControlledSweep,
) -> Result<(Body, SweptFaces)> {
if path.is_empty() || laws.rails.len() > 2 {
return Err(Error::Argument);
}
let made = run(
"sweep_controlled",
|k| -> solidcore::Result<Result<(Brep, Faces)>> {
build(k, profile, profile_normal, path, laws)
},
)?;
let (brep, faces) = made?;
let body = Body::new(brep);
let (_, order) = body.to_record()?;
let start = indices(&order.faces, &faces.start);
let end = indices(&order.faces, &faces.end);
let (Some(start), Some(end)) = (start, end) else {
return Err(Error::Exception);
};
let mut walls = Vec::new();
for (f, curve) in faces.walls {
let Some(i) = order.faces.iter().position(|g| *g == f) else {
return Err(Error::Exception);
};
walls.push((i, curve));
}
Ok((body, SweptFaces { start, end, walls }))
}
/// The kernel's face ids of a controlled sweep's caps and walls.
struct Faces {
start: Vec<FaceId>,
end: Vec<FaceId>,
walls: Vec<(FaceId, usize)>,
}
fn build(
k: &mut Kernel,
profile: &[Curve],
profile_normal: [f64; 3],
path: &[Piece],
laws: &ControlledSweep,
) -> solidcore::Result<Result<(Brep, Faces)>> {
let made: Made = match parse(k, profile) {
Ok(m) => m,
Err(e) => return Ok(Err(e)),
};
let mut rails: Vec<KCurve> = Vec::new();
for rail in &laws.rails {
let rail_made = match parse(k, std::slice::from_ref(rail)) {
Ok(m) => m,
Err(e) => return Ok(Err(e)),
};
let [c] = rail_made.curves.as_slice() else {
return Ok(Err(Error::Argument));
};
rails.push(c.clone());
}
let kpath = Path {
pieces: path.iter().map(kernel_piece).collect(),
};
if !kpath.corner_fractions()?.is_empty() {
return Ok(Err(Error::Unsupported(
"a controlled sweep along a path with corners (its runs meet at mitres: #13639)",
)));
}
if kpath.is_closed()? {
return Ok(Err(Error::Unsupported(
"a controlled sweep round a closed path (its last section on its first: #13638)",
)));
}
let klaws = Laws {
orientation: match laws.orientation {
Orientation::Perpendicular => KOrientation::Perpendicular,
Orientation::Parallel => KOrientation::Parallel,
Orientation::Direction(d) => KOrientation::Direction(v3(d)),
},
twist: laws.twist,
end_scale: laws.end_scale,
rails: rails.iter().collect(),
rail_fit: match laws.rail_fit {
RailFit::Scale => KRailFit::Scale,
RailFit::Stretch => KRailFit::Stretch,
},
min_crossing: MIN_PARALLEL_CROSSING,
min_across: MIN_DIRECTION_ACROSS,
max_twist: MAX_TWIST,
end_scale_within: [MIN_SCALE, MAX_SCALE],
};
let fractions = stations::fractions(&kpath, &klaws)?;
let at: Vec<Stop> = fractions.into_iter().map(Stop::At).collect();
let st = stations::stations(k, &kpath, v3(profile_normal), &klaws, &at)?;
let Some(first) = st.first() else {
return Ok(Err(Error::Argument));
};
let mut sections = Vec::new();
let mut counts = Vec::new();
for s in &st {
let map = Affine {
m: s.m.map(p3),
t: p3(s.translation(first)),
};
let t = map.transform();
for c in &made.curves {
let Some(b) = bspline(c)? else {
return Ok(Err(Error::Argument));
};
match b.affine_image(&t) {
Ok((image, _)) => sections.push(KCurve::from(image)),
Err(e) => return Ok(Err(status(e))),
}
}
counts.push(made.curves.len());
}
let degree = st.len().saturating_sub(1).min(3);
let refs: Vec<Option<&KCurve>> = sections.iter().map(Some).collect();
let (skin, sources) = match create_skin_primitive_with_sources(k, &counts, &refs, degree, true)
{
Ok(r) => r,
Err(e) => return Ok(Err(status(e))),
};
let traced = match sources {
SkinSources::Traced(walls) => walls,
SkinSources::NotTraced { why } => return Ok(Err(Error::Unsupported(not_traced(why)))),
};
// each wall holds one curve of each section, the same profile curve throughout: the
// indices c, c + n, c + 2 n, ... of the sections' curves (n the profile's)
let n = made.curves.len();
let mut walls = Vec::new();
for (f, curves) in traced {
let Some(&c) = curves.first() else {
return Ok(Err(Error::Unsupported(
"a wall the skin traced to no profile curve",
)));
};
let one_curve = c < n
&& curves.len() == st.len()
&& curves.iter().enumerate().all(|(s, &ci)| ci == c + s * n);
if !one_curve {
return Ok(Err(Error::Unsupported(
"a wall not swept from one curve of each section",
)));
}
walls.push((f, c));
}
let faces = Faces {
start: skin.start_faces,
end: skin.end_faces,
walls,
};
Ok(match finish(k, skin.brep, Expect::SOLID)? {
Ok(b) => Ok((b, faces)),
Err(e) => Err(e),
})
}
/// The refusal for a skin the kernel did not trace, by its reason.
fn not_traced(why: NotTraced) -> &'static str {
match why {
NotTraced::OpenProfiles => "a skin not traced to its profile curves: open profiles",
NotTraced::SectionsNotKnotIdentical => {
"a skin not traced to its profile curves: sections whose knots differ"
}
NotTraced::SkinMovedKnots => {
"a skin not traced to its profile curves: its knots are not the sections'"
}
NotTraced::FaceOffKnots => {
"a skin not traced to its profile curves: a wall's bounds are not its knots"
}
NotTraced::EdgeUntraced => {
"a skin not traced to its profile curves: a profile edge with no recorded curve"
}
NotTraced::JoinedCurveUnreadable => {
"a skin not traced to its profile curves: a section's curve could not be read"
}
NotTraced::SurfaceKnotsUnreadable => {
"a skin not traced to its profile curves: its knots could not be read"
}
NotTraced::FaceDomainUnreadable => {
"a skin not traced to its profile curves: a wall's domain could not be read"
}
NotTraced::SideFacesNotReached => {
"a skin not traced to its profile curves: its walls were not made"
}
}
}
+88 -98
View File
@@ -8,16 +8,11 @@
//! one another, and faces meeting at less than it all along an edge make it smooth. Nothing
//! here picks one (SolidCraft passes its shaded view's half a degree).
//!
//! - [`crate::blend::tangent_chain`] (its walk is here): the picked edges, then every edge
//! that continues one of them
//! - [`tangent_chain_ordered`]: the picked edges, then every edge that continues one of them
//! within `angle` at a shared vertex, and so on. An edge does not join when it is not
//! between two faces (a one-face edge), when it is smooth (its faces meet tangentially all
//! along it, a seam among them), or when it turns over (convex where the edge it continues
//! is concave, or the reverse).
//!
//! The kernel's own blend walk (`blend::blend_refusal::tangent_chain`, `continuing_edges`) is
//! a different job and stays beside it: which edges a blend of an edge runs on into at the
//! blend corner, from the edge's blend edge use, as fillet construction asks it.
//! - [`tangent_face_chain_ordered`]: the picked faces, then every face across a smooth edge
//! from one of them, and so on.
//!
@@ -25,17 +20,15 @@
//! the angle, [`solidcore::query::tangent_edge::Tangency::Undecided`]) joins nothing and is
//! named in the answer.
use std::collections::{BTreeMap, BTreeSet};
use solidcore::query::edge_turn::{convex_at, turn_at};
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
use solidcore::topology::{edge, face, vertex};
use solidcore::topology::edge;
use solidcore::{Brep, EdgeId, FaceId, Kernel};
use crate::session::with_body;
use crate::{Body, Error, Order, Result};
/// What [`crate::blend::tangent_chain`] and [`tangent_face_chain_ordered`] answer, in record
/// What [`tangent_chain_ordered`] and [`tangent_face_chain_ordered`] answer, in record
/// indices.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Chain {
@@ -47,7 +40,7 @@ pub struct Chain {
/// A kernel edge's smoothness, decided once.
struct Smooth {
known: BTreeMap<EdgeId, Option<Tangency>>,
known: Vec<(EdgeId, Option<Tangency>)>,
}
impl Smooth {
@@ -58,11 +51,11 @@ impl Smooth {
e: EdgeId,
angle: f64,
) -> solidcore::Result<Option<Tangency>> {
if let Some(t) = self.known.get(&e) {
if let Some((_, t)) = self.known.iter().find(|(f, _)| *f == e) {
return Ok(*t);
}
let t = tangent_all_along(k, b, e, angle)?;
self.known.insert(e, t);
self.known.push((e, t));
Ok(t)
}
}
@@ -76,96 +69,98 @@ fn index_of<T: PartialEq + Copy>(list: &[T], x: T) -> Option<usize> {
list.iter().position(|&y| y == x)
}
/// The edge walk of [`crate::blend::tangent_chain`] on `b`, `list` its edges in record order.
pub(crate) fn edge_walk(
k: &Kernel,
b: &mut Brep,
list: &[EdgeId],
/// The tangent chain of `body`'s edges at record indices `edges` (through `order`, its
/// record's [`Order`]), as the module documentation describes, at the caller's `angle`;
/// `passed_over` names edges a pick passes over, which never join.
///
/// # Errors
/// [`Error::Argument`] for no edges, an angle not finite and strictly between 0 and a right
/// angle, or an index past the order or naming an edge the body does not have; the kernel's
/// refusals (a face with no NURBS form, an edge use with no pcurve, an edge too costly to
/// decide).
pub fn tangent_chain_ordered(
body: &Body,
order: &Order,
edges: &[usize],
passed_over: &[usize],
angle: f64,
) -> solidcore::Result<Result<Chain>> {
) -> Result<Chain> {
if edges.is_empty() || !angle_ok(angle) {
return Ok(Err(Error::Argument));
return Err(Error::Argument);
}
let mut chain: Vec<EdgeId> = Vec::new();
for &i in edges {
match list.get(i) {
Some(&e) if b.edge_list().contains(&e) => {
if !chain.contains(&e) {
chain.push(e);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let mut skip: BTreeSet<EdgeId> = BTreeSet::new();
for &i in passed_over {
match list.get(i) {
Some(&e) if b.edge_list().contains(&e) => {
skip.insert(e);
}
_ => return Ok(Err(Error::Argument)),
}
}
// each edge's record index, for the candidates' order
let index: BTreeMap<EdgeId, usize> = list.iter().enumerate().map(|(i, &e)| (e, i)).collect();
let mut smooth = Smooth {
known: BTreeMap::new(),
};
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&e) = chain.get(next) {
next += 1;
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
for v in ends.into_iter().flatten() {
// the edges at the vertex, in record order
let mut at: Vec<EdgeId> = vertex::edges(b, v, None)?;
at.sort_by_key(|f| index.get(f).copied().unwrap_or(usize::MAX));
at.dedup();
for f in at {
if chain.contains(&f) || skip.contains(&f) {
continue;
}
// the cheap questions first: does it continue this edge, and on the same
// side; only then is its smoothness certified
if !turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
continue;
}
let turns_over = matches!(
(convex_at(k, b, e, v, angle)?, convex_at(k, b, f, v, angle)?),
(Some(a), Some(c)) if a != c
);
if turns_over {
continue;
}
match smooth.of(k, b, f, angle)? {
Some(Tangency::NotTangent) => chain.push(f),
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&f) {
undecided.push(f);
}
let (copy, mapped) = body.copy_with_order(order)?;
with_body("tangent_chain_ordered", &copy, |k, b| {
let list = mapped.edges.clone();
let mut chain: Vec<EdgeId> = Vec::new();
for &i in edges {
match list.get(i) {
Some(&e) if b.edge_list().contains(&e) => {
if !chain.contains(&e) {
chain.push(e);
}
}
_ => return Ok(Err(Error::Argument)),
}
}
let skip: Vec<EdgeId> = passed_over
.iter()
.filter_map(|&i| list.get(i).copied())
.collect();
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&e) = chain.get(next) {
next += 1;
let ends = [edge::start_vertex(b, e)?, edge::end_vertex(b, e)?];
for v in ends.into_iter().flatten() {
for &f in &list {
if chain.contains(&f) || skip.contains(&f) {
continue;
}
let meets = [edge::start_vertex(b, f)?, edge::end_vertex(b, f)?];
if !meets.contains(&Some(v)) {
continue;
}
match smooth.of(k, b, f, angle)? {
None | Some(Tangency::Tangent) => continue,
Some(Tangency::Undecided { .. }) => {
if !undecided.contains(&f) {
undecided.push(f);
}
continue;
}
Some(Tangency::NotTangent) => {}
}
let turns_over = matches!(
(convex_at(k, b, e, v)?, convex_at(k, b, f, v)?),
(Some(a), Some(c)) if a != c
);
if turns_over {
continue;
}
if turn_at(k, b, e, f, v)?.is_some_and(|turn| turn < angle) {
chain.push(f);
}
None | Some(Tangency::Tangent) => {}
}
}
}
}
let indices =
|of: &[EdgeId]| -> Option<Vec<usize>> { of.iter().map(|&e| index_of(list, e)).collect() };
Ok(match (indices(&chain), indices(&undecided)) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
let indices = |of: &[EdgeId]| -> Option<Vec<usize>> {
of.iter().map(|&e| index_of(&list, e)).collect()
};
Ok(match (indices(&chain), indices(&undecided)) {
(Some(members), Some(undecided)) => Ok(Chain { members, undecided }),
_ => Err(Error::Exception),
})
})?
}
/// The faces of `body` at record indices `faces` (through `order`), then every face across a
/// smooth edge from one of them, and so on (module documentation), at the caller's `angle`.
///
/// # Errors
/// [`Error::Argument`] for no faces, an angle as for [`crate::blend::tangent_chain`], or an
/// index past the order or naming a face the body does not have; the kernel's refusals as for
/// [`crate::blend::tangent_chain`].
/// [`Error::Argument`] for no faces, an angle as for [`tangent_chain_ordered`], or an index
/// past the order or naming a face the body does not have; the kernel's refusals as for
/// [`tangent_chain_ordered`].
pub fn tangent_face_chain_ordered(
body: &Body,
order: &Order,
@@ -190,21 +185,16 @@ pub fn tangent_face_chain_ordered(
_ => return Ok(Err(Error::Argument)),
}
}
let index: BTreeMap<EdgeId, usize> =
edge_list.iter().enumerate().map(|(i, &e)| (e, i)).collect();
let mut smooth = Smooth {
known: BTreeMap::new(),
};
let mut smooth = Smooth { known: Vec::new() };
let mut undecided: Vec<EdgeId> = Vec::new();
let mut next = 0;
while let Some(&f) = chain.get(next) {
next += 1;
// the face's edges, in record order
let mut around = face::p4::edges(b, f, None, false, None)?;
around.sort_by_key(|e| index.get(e).copied().unwrap_or(usize::MAX));
around.dedup();
for e in around {
for &e in &edge_list {
let across = solidcore::query::edge::edge_faces(b, e)?;
if !across.contains(&f) {
continue;
}
match smooth.of(k, b, e, angle)? {
Some(Tangency::Tangent) => {}
Some(Tangency::Undecided { .. }) => {
+7 -12
View File
@@ -1027,13 +1027,11 @@ fn slot() -> Body {
/// The slot's top outline is one tangent chain: from its front straight, all four top edges;
/// a box edge runs on into nothing. Indices are checked before the kernel.
#[test]
fn the_tangent_chain_runs_round_the_slot() {
fn the_tangent_chain_is_the_kernels() {
all_fixes();
let b = slot();
let front = edge_at(&b, [5.0, 0.0, 2.0]);
let chain = tangent_chain(&b, None, &[front], &[], HALF_DEGREE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
let chain = chain.members;
let chain = tangent_chain(&b, &[front], HALF_DEGREE).unwrap();
assert_eq!(chain.len(), 4, "{chain:?}");
assert_eq!(chain[0], front);
for p in [[5.0, 4.0, 2.0], [12.0, 2.0, 2.0], [-2.0, 2.0, 2.0]] {
@@ -1041,11 +1039,10 @@ fn the_tangent_chain_runs_round_the_slot() {
}
let bx = block();
let e = top_front(&bx);
let chain = |edges: &[usize], angle: f64| tangent_chain(&bx, None, edges, &[], angle);
assert_eq!(chain(&[e], HALF_DEGREE).unwrap().members, vec![e]);
assert_eq!(chain(&[99], 0.01), Err(Error::Argument));
assert_eq!(chain(&[e], 0.0), Err(Error::Argument));
assert_eq!(chain(&[], 0.01), Err(Error::Argument));
assert_eq!(tangent_chain(&bx, &[e], HALF_DEGREE).unwrap(), vec![e]);
assert_eq!(tangent_chain(&bx, &[99], 0.01), Err(Error::Argument));
assert_eq!(tangent_chain(&bx, &[e], 0.0), Err(Error::Argument));
assert_eq!(tangent_chain(&bx, &[], 0.01), Err(Error::Argument));
}
/// The slot's seam, where its straight side runs into its round end, has no blend: its faces
@@ -1284,9 +1281,7 @@ fn a_cancelled_long_fillet_stops_promptly() {
d(a).total_cmp(&d(b))
})
.unwrap();
let chain = tangent_chain(&coil, None, &[edge], &[], 0.1)
.unwrap()
.members;
let chain = tangent_chain(&coil, &[edge], 0.1).unwrap();
let token = CancelToken::new();
let watched = token.clone();
let worker = std::thread::spawn(move || {
-249
View File
@@ -1,249 +0,0 @@
//! `sweep::controlled::sweep_controlled` (#13664): a controlled sweep through the kernel's
//! stations, and which faces came from where, against closed forms.
//!
//! - a square of side 2 about the path, swept 10 along a line with end scale `k`: its sections
//! vary linearly, which the cubic skin reproduces, so the volume is the frustum's
//! `A L (1 + k + k^2) / 3`;
//! - its faces: one start cap in z = 0, one end cap in z = L, and a wall per profile line
//! holding that line;
//! - twisted a quarter turn and scaled, the end cap's corners are the start's turned and scaled;
//! - one straight rail diverging from the path scales it as end scale `(d_0 + s L) / d_0`;
//! - a path with a corner and a closed path are refused, named; so are a twist and end scale
//! past SolidCraft's limits, which live here, not in the kernel.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore_ops::sweep::controlled::{sweep_controlled, ControlledSweep, Piece, SweptFaces};
use solidcore_ops::sweep::Curve;
use solidcore_ops::{Body, Error};
use solidcore_record::brep::{Brep as Record, Loop};
use std::f64::consts::{FRAC_PI_2, PI};
const L: f64 = 10.0;
/// The square of side 2 about the origin in z = 0, counter-clockwise from (-1, -1).
fn square() -> Vec<Curve> {
let c = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
(0..4)
.map(|i| Curve::Line {
start: [c[i][0], c[i][1], 0.0],
end: [c[(i + 1) % 4][0], c[(i + 1) % 4][1], 0.0],
})
.collect()
}
fn up(len: f64) -> Vec<Piece> {
vec![Piece::Line {
a: [0.0, 0.0, 0.0],
b: [0.0, 0.0, len],
}]
}
fn swept(laws: &ControlledSweep) -> (Body, SweptFaces) {
sweep_controlled(&square(), [0.0, 0.0, 1.0], &up(L), laws).unwrap()
}
/// The points of the vertices of face `f`'s loops.
fn face_points(r: &Record, f: usize) -> Vec<[f64; 3]> {
let mut out = Vec::new();
for lp in &r.faces[f].loops {
if let Loop::Edges { edges } = lp {
for oe in edges {
for v in r.edges[oe.edge as usize].vertices {
out.push(r.vertices[v as usize].point);
}
}
}
}
out
}
fn has(points: &[[f64; 3]], p: [f64; 3]) -> bool {
points
.iter()
.any(|q| (0..3).all(|i| (q[i] - p[i]).abs() < 1e-9))
}
#[test]
fn a_scaled_square_is_the_frustum() {
for k in [1.0, 0.5, 2.0, 3.0] {
let laws = ControlledSweep {
end_scale: k,
..ControlledSweep::default()
};
let (b, _) = swept(&laws);
let v = b.volume(1e-10).unwrap();
let want = 4.0 * L * (1.0 + k + k * k) / 3.0;
assert!((v - want).abs() <= 1e-9 * want, "k {k}: {v} vs {want}");
}
}
#[test]
fn its_faces_are_the_caps_and_a_wall_per_profile_line() {
let (b, faces) = swept(&ControlledSweep::default());
let (r, _) = b.to_record().unwrap();
assert_eq!(faces.start.len(), 1, "{faces:?}");
assert_eq!(faces.end.len(), 1, "{faces:?}");
assert_eq!(faces.walls.len(), 4, "{faces:?}");
assert_eq!(r.faces.len(), 6);
let at = |f: usize| face_points(&r, f);
assert!(at(faces.start[0]).iter().all(|p| p[2].abs() < 1e-12));
assert!(at(faces.end[0]).iter().all(|p| (p[2] - L).abs() < 1e-12));
for (i, c) in square().iter().enumerate() {
let Curve::Line { start, end } = c else {
panic!("a line");
};
let wall = faces
.walls
.iter()
.find(|(_, curve)| *curve == i)
.expect("a wall for each line")
.0;
let points = at(wall);
assert!(
has(&points, *start) && has(&points, *end),
"line {i}: {points:?}"
);
}
}
/// A profile given out of order, one side reversed: each wall still holds its own line (the
/// kernel's trace, not the skin's face order).
#[test]
fn a_shuffled_profile_s_walls_are_its_own_lines() {
let mut profile = square();
profile.swap(0, 2);
profile.swap(1, 3);
if let Curve::Line { start, end } = &mut profile[1] {
std::mem::swap(start, end);
}
let (b, faces) = sweep_controlled(
&profile,
[0.0, 0.0, 1.0],
&up(L),
&ControlledSweep::default(),
)
.unwrap();
let (r, _) = b.to_record().unwrap();
assert_eq!(faces.walls.len(), 4, "{faces:?}");
let mut seen: Vec<usize> = faces.walls.iter().map(|&(_, c)| c).collect();
seen.sort_unstable();
assert_eq!(seen, [0, 1, 2, 3], "{faces:?}");
for &(wall, i) in &faces.walls {
let Curve::Line { start, end } = profile[i] else {
panic!("a line");
};
let points = face_points(&r, wall);
// the wall runs from its line at the start to the line moved up L at the end
let up_l = |p: [f64; 3]| [p[0], p[1], p[2] + L];
assert!(
has(&points, start) && has(&points, end) && has(&points, up_l(start)),
"line {i}: {points:?}"
);
}
}
#[test]
fn a_twisted_scaled_square_ends_turned_and_scaled() {
let (twist, k) = (FRAC_PI_2, 2.0);
let laws = ControlledSweep {
twist,
end_scale: k,
..ControlledSweep::default()
};
let (b, faces) = swept(&laws);
let (r, _) = b.to_record().unwrap();
let end = face_points(&r, faces.end[0]);
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
// a quarter turn: sin 1, cos 0 (ops computes no trigonometry, docs/layers.md)
let (s, c) = (1.0, 0.0);
let want = [k * (x * c - y * s), k * (x * s + y * c), L];
assert!(has(&end, want), "{want:?} in {end:?}");
}
}
#[test]
fn a_diverging_rail_scales_as_its_distance() {
let (d0, slope) = (4.0, 0.1);
let rail = Curve::Line {
start: [d0, 0.0, 0.0],
end: [d0 + slope * L, 0.0, L],
};
let laws = ControlledSweep {
rails: vec![rail],
..ControlledSweep::default()
};
let (b, _) = swept(&laws);
let k = (d0 + slope * L) / d0;
let want = 4.0 * L * (1.0 + k + k * k) / 3.0;
let v = b.volume(1e-10).unwrap();
assert!((v - want).abs() <= 1e-9 * want, "{v} vs {want}");
}
#[test]
fn a_cornered_or_closed_path_is_refused() {
let corner = vec![
Piece::Line {
a: [0.0, 0.0, 0.0],
b: [0.0, 0.0, L],
},
Piece::Line {
a: [0.0, 0.0, L],
b: [L, 0.0, L],
},
];
let err = sweep_controlled(
&square(),
[0.0, 0.0, 1.0],
&corner,
&ControlledSweep::default(),
)
.unwrap_err();
assert!(
matches!(err, Error::Unsupported(w) if w.contains("corners")),
"{err:?}"
);
let closed = vec![Piece::Arc {
centre: [-20.0, 0.0, 0.0],
axis: [0.0, 1.0, 0.0],
start: [0.0, 0.0, 0.0],
angle: 2.0 * PI,
}];
let err = sweep_controlled(
&square(),
[0.0, 0.0, 1.0],
&closed,
&ControlledSweep::default(),
)
.unwrap_err();
assert!(
matches!(err, Error::Unsupported(w) if w.contains("closed")),
"{err:?}"
);
}
/// SolidCraft's limits are the ops entry's, passed to the kernel's laws: five turns of twist
/// and an end scale past 0.01 to 100 are refused, though the kernel's own defaults take them.
#[test]
fn solidcraft_s_limits_refuse_here() {
use solidcore_ops::sweep::controlled::{MAX_SCALE, MAX_TWIST, MIN_SCALE};
let refused = |laws: ControlledSweep| {
let got = sweep_controlled(&square(), [0.0, 0.0, 1.0], &up(L), &laws);
assert!(matches!(got, Err(Error::Status(_))), "{laws:?}: {got:?}");
};
refused(ControlledSweep {
twist: 5.0 * 2.0 * PI,
..ControlledSweep::default()
});
for end_scale in [0.5 * MIN_SCALE, 2.0 * MAX_SCALE] {
refused(ControlledSweep {
end_scale,
..ControlledSweep::default()
});
}
assert_eq!(MAX_TWIST.to_bits(), (8.0 * PI).to_bits(), "four turns");
}
@@ -49,9 +49,7 @@ fn pick(b: &Body, p: [f64; 3]) -> usize {
fn round(b: &Body, points: &[[f64; 3]], r: f64, chain: bool) {
let seeds: Vec<usize> = points.iter().map(|&p| pick(b, p)).collect();
let edges = if chain {
let chain = tangent_chain(b, None, &seeds, &[], TANGENT).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
chain.members
tangent_chain(b, &seeds, TANGENT).unwrap()
} else {
seeds
};
+12 -130
View File
@@ -1,4 +1,4 @@
//! `blend::tangent_chain` and `tangent::tangent_face_chain_ordered` (#3141), on record order.
//! `tangent::tangent_chain_ordered` and `tangent_face_chain_ordered` (#3141), on record order.
//!
//! A stadium prism: two straight walls and two half-cylinders, 4 high. Its top outline is
//! four edges each running on tangentially into the next, so a pick of one top edge chains
@@ -19,10 +19,9 @@
clippy::indexing_slicing
)] // test helpers
use solidcore_ops::blend::tangent_chain;
use solidcore_ops::boolean::{boolean, BooleanOp};
use solidcore_ops::sweep::{extrude, Curve, DraftCorner};
use solidcore_ops::tangent::tangent_face_chain_ordered;
use solidcore_ops::tangent::{tangent_chain_ordered, tangent_face_chain_ordered};
use solidcore_ops::{make_box, Body};
use solidcore_record::brep::{Brep as Record, Surface};
use std::f64::consts::{FRAC_PI_2, PI};
@@ -31,8 +30,8 @@ const H: f64 = 4.0;
/// SolidCraft's smooth-edge angle, the caller's: half a degree.
const ANGLE: f64 = 0.5 * PI / 180.0;
fn stadium_profile() -> [Curve; 4] {
[
fn stadium() -> Body {
let profile = [
Curve::Line {
start: [0.0, 0.0, 0.0],
end: [20.0, 0.0, 0.0],
@@ -57,11 +56,7 @@ fn stadium_profile() -> [Curve; 4] {
start: FRAC_PI_2,
end: 1.5 * PI,
},
]
}
fn stadium() -> Body {
let profile = stadium_profile();
];
extrude(
&profile,
[0.0, 0.0, 1.0],
@@ -122,7 +117,7 @@ fn a_stadium_s_top_outline_is_one_chain() {
let b = stadium();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let chain = tangent_chain(&b, Some(&order), &[front], &[], ANGLE).unwrap();
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 4, "{chain:?}");
assert_eq!(chain.members[0], front);
@@ -135,7 +130,7 @@ fn a_passed_over_edge_stops_the_chain() {
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let back = edge(&r, [20.0, 10.0, H], [0.0, 10.0, H]);
let chain = tangent_chain(&b, Some(&order), &[front], &[back], ANGLE).unwrap();
let chain = tangent_chain_ordered(&b, &order, &[front], &[back], ANGLE).unwrap();
assert_eq!(chain.members.len(), 3, "{chain:?}");
assert!(!chain.members.contains(&back));
}
@@ -145,7 +140,7 @@ fn a_box_edge_chains_alone() {
let b = make_box([0.0; 3], [10.0, 6.0, H]).unwrap();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [10.0, 0.0, H]);
let chain = tangent_chain(&b, Some(&order), &[front], &[], ANGLE).unwrap();
let chain = tangent_chain_ordered(&b, &order, &[front], &[], ANGLE).unwrap();
assert_eq!(chain.members, vec![front]);
assert!(chain.undecided.is_empty());
}
@@ -178,13 +173,6 @@ fn a_box_face_chains_alone() {
/// The plate of side `plate` with the stadium boss on top.
fn plate_with_stadium(plate: f64) -> Body {
let base = make_box([-plate / 2.0, -plate / 2.0, 0.0], [plate, plate, 10.0]).unwrap();
boolean(base, stadium_boss(), BooleanOp::Union)
.unwrap()
.unwrap()
}
/// The 50 x 10 stadium boss about y = 0, from z = 10 to 20.
fn stadium_boss() -> Body {
let z = 10.0;
let profile = [
Curve::Line {
@@ -212,7 +200,7 @@ fn stadium_boss() -> Body {
end: 1.5 * PI,
},
];
extrude(
let boss = extrude(
&profile,
[0.0, 0.0, 1.0],
10.0,
@@ -220,7 +208,8 @@ fn stadium_boss() -> Body {
DraftCorner::default(),
true,
)
.unwrap()
.unwrap();
boolean(base, boss, BooleanOp::Union).unwrap().unwrap()
}
#[test]
@@ -229,7 +218,7 @@ fn one_pick_follows_the_boss_rim_at_any_part_size() {
let b = plate_with_stadium(plate);
let (r, order) = b.to_record().unwrap();
let back = edge(&r, [20.0, 5.0, 20.0], [-20.0, 5.0, 20.0]);
let chain = tangent_chain(&b, Some(&order), &[back], &[], ANGLE).unwrap();
let chain = tangent_chain_ordered(&b, &order, &[back], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "plate {plate}: {chain:?}");
assert_eq!(chain.members.len(), 4, "plate {plate}: {chain:?}");
let at_top = |i: usize| {
@@ -244,110 +233,3 @@ fn one_pick_follows_the_boss_rim_at_any_part_size() {
);
}
}
/// A prism on (0, 0), (10, 0), (20, 10 tan a), (20, 12), (0, 12), `a` 0.3 degrees: its walls
/// at (10, 0) meet `a` apart. A pick of the wall on y = 0 joins the next wall at half a degree,
/// not at 0.2 degrees, and at 0.3 degrees, the angle itself, the kernel decides neither: the
/// edge between them is listed undecided and joins nothing.
#[test]
fn walls_at_the_asked_angle_are_listed_undecided() {
// 10 tan(0.3 degrees), as a literal: ops tests use no float methods
let rise = 0.052_360_356_057_001_275;
let pts = [
[0.0, 0.0, 0.0],
[10.0, 0.0, 0.0],
[20.0, rise, 0.0],
[20.0, 12.0, 0.0],
[0.0, 12.0, 0.0],
];
let profile: Vec<Curve> = (0..5)
.map(|i| Curve::Line {
start: pts[i],
end: pts[(i + 1) % 5],
})
.collect();
let b = extrude(
&profile,
[0.0, 0.0, 1.0],
H,
0.0,
DraftCorner::default(),
true,
)
.unwrap();
let (r, order) = b.to_record().unwrap();
let front: Vec<usize> = plane_along(&r, [0.0, 1.0, 0.0])
.into_iter()
.filter(|&f| match r.faces[f].surface {
Surface::Plane { origin, .. } => origin[1].abs() < 1e-9,
_ => false,
})
.collect();
assert_eq!(front.len(), 1, "{front:?}");
let kink = edge(&r, [10.0, 0.0, 0.0], [10.0, 0.0, H]);
// 0.5, 0.2 and 0.3 degrees in radians
let (half, fifth, level) = (
0.008_726_646_259_971_648,
0.003_490_658_503_988_659,
0.005_235_987_755_982_988,
);
let at = |angle: f64| tangent_face_chain_ordered(&b, &order, &front, angle).unwrap();
let wide = at(half);
assert_eq!(wide.members.len(), 2, "{wide:?}");
assert!(wide.undecided.is_empty(), "{wide:?}");
let narrow = at(fifth);
assert_eq!(narrow.members, front, "{narrow:?}");
assert!(narrow.undecided.is_empty(), "{narrow:?}");
let level = at(level);
assert_eq!(level.members, front, "{level:?}");
assert_eq!(level.undecided, vec![kink], "{level:?}");
}
/// The stadium as a sheet (extruded without caps): its top edges have one face each, so a pick
/// of one chains nothing more: an edge not between two faces does not join.
#[test]
fn a_sheet_s_one_face_edges_do_not_join() {
let profile = stadium_profile();
let b = extrude(
&profile,
[0.0, 0.0, 1.0],
H,
0.0,
DraftCorner::default(),
false,
)
.unwrap();
let (r, order) = b.to_record().unwrap();
let front = edge(&r, [0.0, 0.0, H], [20.0, 0.0, H]);
let chain = tangent_chain(&b, Some(&order), &[front], &[], ANGLE).unwrap();
assert_eq!(chain.members, vec![front], "{chain:?}");
assert!(chain.undecided.is_empty(), "{chain:?}");
}
/// A plate 80 x 40 x 10 at y >= 0 with the 50 x 10 stadium boss, 10 tall, on its top about
/// y = 0, so the boss's front half overhangs the plate's front face. The boss's base outline at
/// z = 10 is concave where it stands on the plate (the back straight and the back halves of its
/// end arcs) and convex where it overhangs (the boss's underside meets its wall). At
/// (+-25, 0, 10) each end arc's back quarter runs on tangentially into its front quarter, of
/// the other convexity: a pick of the back straight chains the back straight and the two back
/// quarters, and stops there.
#[test]
fn a_chain_stops_where_it_would_turn_over() {
let base = make_box([-40.0, 0.0, 0.0], [80.0, 40.0, 10.0]).unwrap();
let b = boolean(base, stadium_boss(), BooleanOp::Union)
.unwrap()
.unwrap();
let (r, order) = b.to_record().unwrap();
let back = edge(&r, [20.0, 5.0, 10.0], [-20.0, 5.0, 10.0]);
let chain = tangent_chain(&b, Some(&order), &[back], &[], ANGLE).unwrap();
assert!(chain.undecided.is_empty(), "{chain:?}");
assert_eq!(chain.members.len(), 3, "{chain:?}");
for &e in &chain.members {
for v in r.edges[e].vertices {
let q = r.vertices[v as usize].point;
assert!((q[2] - 10.0).abs() < 1e-9 && q[1] > -1e-9, "{e}: {q:?}");
}
}
let front = edge(&r, [-20.0, -5.0, 10.0], [20.0, -5.0, 10.0]);
assert!(!chain.members.contains(&front), "{chain:?}");
}
+23 -16
View File
@@ -71,7 +71,6 @@ use crate::nurbs::b_spline_surface::BSplineSurface;
use crate::nurbs::b_spline_surface_n_lib::p2::OrientType;
use crate::nurbs::crv_adv::p1::CurveEnd;
use crate::nurbs::knots::Side as KnotSide;
use crate::query::edge_turn::{leaving_tangent, Leaving};
use crate::surface::procedural_blend::corner::CornerDefinition;
use crate::surface::surface::{Surface, SurfaceKind};
use crate::topology::brep::p7::{make_face_with_curves, FaceCurvesInput};
@@ -493,10 +492,7 @@ impl SolutionObject for MxObject {
/// its end, where it is turned. Inside the edge means one-sided: from the interval above
/// the start (`from_left` true) and from the interval below the end (`from_left` false);
/// [`edge::end_tangent`] takes both from above, which at the end reads past the edge. A
/// closed edge leaves its vertex at its start (fork b785227). The evaluation is
/// [`crate::query::edge_turn::leaving_derivative`]'s ([`crate::query::edge_turn::leaving_tangent`]);
/// this adds the closed edge's start and
/// this module's errors (a vertex the edge does not meet, no curve, a tangent of no length).
/// closed edge leaves its vertex at its start (fork b785227).
fn leaving(k: &Kernel, b: &Brep, e: EdgeId, vertex: VertexId, op: &'static str) -> Result<Vec3> {
let at_start = edge::start_vertex(b, e)? == Some(vertex);
if !at_start && edge::end_vertex(b, e)? != Some(vertex) {
@@ -506,19 +502,30 @@ fn leaving(k: &Kernel, b: &Brep, e: EdgeId, vertex: VertexId, op: &'static str)
"the edge does not meet the vertex",
));
}
// the one evaluation, inside the edge and turned away from the vertex; each case keeps
// this module's typed refusal
let d = match leaving_tangent(k, b, e, at_start)? {
Leaving::NoCurve => return Err(SolidError::blend_missing(op, "the edge's curve")),
Leaving::NoTangent => {
return Err(SolidError::blend_failed(
let curve =
edge::curve(b, e)?.ok_or_else(|| SolidError::blend_missing(op, "the edge's curve"))?;
let ivl = edge::interval(b, e)?;
let pv = curve.evaluate(
k,
if at_start { ivl.min() } else { ivl.max() },
1,
at_start,
true,
)?;
let mut d = pv
.get(1)
.copied()
.ok_or_else(|| {
SolidError::blend_failed(
op,
"the curve's first derivative at the vertex is not there",
))
}
Leaving::Tangent(d) => d,
};
d.unitize()
)
})?
.unitize()?;
if !at_start {
d = -d;
}
Ok(d)
}
/// `sm_MixedParam`: the parameter of the point of `curve` nearest `pnt`.
+40 -4
View File
@@ -2530,13 +2530,49 @@ fn keep_definition(
"the fillet geometry's surface",
));
};
// The round keeps its definition only where it is proved; a net the certificate refuses
// refuses the fillet (definitions-and-certificates.md §7.1, §7.4), never kept bare.
round.recertify(k, surface)?;
surface.set_exact_round(Some(round));
keep_round_if_proved(k, surface, round, inputs.tol)?;
Ok(())
}
/// `round` kept on `net` where it is proved ([`ExactRound::recertify`]), returning whether it
/// was. The class is read within `tol`, the body's tolerance, and the certificate to rounding.
/// A net the certificate cannot prove on its class's surface (`check_exact_net`'s refusal),
/// but that lies within `tol` of it ([`ExactRound::miss`]), is the round all the same: it
/// keeps no definition and is built. A canal round whose computed spine is not exactly on
/// both supports' offsets (a plane and a cylinder a few degrees off square,
/// tangency-labs/solidcraft#14000) is one.
/// A net further off the class than `tol` is built wrongly, and stays an error, as does the
/// circle class's own refusal (its net certified on its own torus, #3066).
///
/// # Errors
///
/// The certificate's refusal of a net more than `tol` off the class, or of supports that
/// give the class no surface; those of the certificate and of [`ExactRound::miss`].
pub fn keep_round_if_proved(
k: &Kernel,
net: &mut Surface,
round: ExactRound,
tol: f64,
) -> Result<bool> {
match round.recertify(k, net) {
Ok(_) => {
net.set_exact_round(Some(round));
Ok(true)
}
Err(e) => {
let off = matches!(
&e,
SolidError::InvalidInput { operation, .. }
if *operation == crate::surface::implicit::OP_EXACT
);
match off.then(|| round.miss(k, net)).transpose()?.flatten() {
Some(miss) if miss <= tol => Ok(false),
_ => Err(e),
}
}
}
}
/// [`calc_exact_fillet_geom`]'s build by the class (`CalcExactFilletGeom`), without the
/// definition kept.
#[allow(clippy::too_many_arguments)]
@@ -449,34 +449,15 @@ fn plane_section(point: Point3, normal: Vec3, q: &BlendQuadric) -> Result<Conic>
let b1 = g.dot(u);
let b2 = g.dot(v);
let c2 = quad(point, point) + 2.0 * s_bv.dot(point) + dc;
// principal axes of A2: the eigenvalues m +- hypot(h, a12), h = (a11 - a22) / 2, and
// each axis in the form whose terms add (not upstream, which takes the eigenvalues from
// sqrt(tr^2 / 4 - det) and the axes from (a12, lambda - a11): both cancel as the section
// nears a circle, and the conic then misses the quadric by about eps / a^2,
// tangency-labs/solidcraft#15037). The axes are those upstream's give in exact
// arithmetic, signs included: (a12, lambda_0 - a11) = (a12, r - h) is a12 / |a12| times
// (r + h, a12) scaled by |a12| / (r + h), and (a12, lambda_1 - a11) = (a12, -(r + h)) is
// a12 / |a12| times (r - h, -a12) scaled by |a12| / (r - h).
let m = 0.5 * (a11 + a22);
let h = 0.5 * (a11 - a22);
let r = h.hypot(a12);
let adl = [m + r, m - r];
let sg = if a12 < 0.0 { -1.0 } else { 1.0 };
let axes = [
if h >= 0.0 {
[sg * (r + h), sg * a12]
} else {
[a12, r - h]
},
if h <= 0.0 {
[sg * (r - h), -sg * a12]
} else {
[a12, -(r + h)]
},
];
// principal axes of A2
let tr = a11 + a22;
let det = a11 * a22 - a12 * a12;
let disc = max2(0.0, 0.25 * tr * tr - det).sqrt();
let adl = [0.5 * tr + disc, 0.5 * tr - disc];
let mut ade = [[0.0; 2]; 2];
for (kk, (row, &[x, y])) in ade.iter_mut().zip(axes.iter()).enumerate() {
let (mut ex, mut ey) = (x, y);
for (kk, (row, &lk)) in ade.iter_mut().zip(adl.iter()).enumerate() {
let mut ex = a12;
let mut ey = lk - a11;
if ex.abs() + ey.abs() < EFF_ZERO * (a11.abs() + a22.abs() + 1.0) {
ex = if (kk == 0) == (a11 >= a22) { 1.0 } else { 0.0 };
ey = 1.0 - ex;
+2 -296
View File
@@ -25,10 +25,6 @@
//! degenerate), a [`Tessellation`] (all three arrays; upstream fills the ones it is
//! given, each independently, and fails with `SM_ERR_INVALID_INPUT` when none is),
//! and a [`SolutionArray`] for `GlobalPointSolve`.
//! - **Projected extrema at interval ends (#13658, WR-775).** For a projected minimum
//! (maximum), `GlobalPointSolve` returns an end of its interval when the projected
//! distance grows (shrinks) from that end into the interval. Upstream returns no
//! solution there, or a farther (nearer) stationary point.
//! - **Objects.** Solutions name the caller's handles: `GlobalPointSolve` takes the
//! handle of the curve (named by the line drop and the tree solutions, upstream
//! `this`) and of its analytic replacement ([`GlobalPointSolveOptions`]; upstream
@@ -1068,14 +1064,10 @@ impl<'t, O: SolutionObject + 't> CpGlobalSolver<'t, O> {
// ends there: with the exact test alone, an end a rounding past a knot leaves this span
// thinking the answer lies in a next span that never looks, and a point whose foot is at
// the end has no solution at all.
let at_interval_start =
bez_ivl.min() <= interval.min() || are_same(bez_ivl.min(), interval.min());
let at_interval_end =
bez_ivl.max() >= interval.max() || are_same(bez_ivl.max(), interval.max());
if at_interval_start {
if bez_ivl.min() <= interval.min() || are_same(bez_ivl.min(), interval.min()) {
conts[0] = ContinuityType::Discontinuous;
}
if at_interval_end {
if bez_ivl.max() >= interval.max() || are_same(bez_ivl.max(), interval.max()) {
conts[1] = ContinuityType::Discontinuous;
}
@@ -1213,61 +1205,6 @@ impl<'t, O: SolutionObject + 't> CpGlobalSolver<'t, O> {
return Ok(needs_more_subdivision);
}
// (not upstream, #13658, WR-775) An end of the solver's interval where the projected
// distance is not stationary is no root of the property search below, and the loop gives
// up on the span when its first guess finds no root, before its own end point branch.
// A point whose nearest (farthest) projected point is that end then had no solution
// at all. The end is a solution when the projected distance grows (shrinks) from it
// into the interval; an end where it is stationary is left to the search.
if op == SolverOperation::ProjectedMinimize || op == SolverOperation::ProjectedMaximize {
let normal = self.opt(0)?;
// (at the interval's end, continuity side, parameter, is the start)
let ends = [
(at_interval_start, 0, bez_ivl.min(), true),
(at_interval_end, 1, bez_ivl.max(), false),
];
for (at_bound, side, parameter, is_start) in ends {
let open = conts
.get(side)
.is_some_and(|c| (*c as i32) < (ContinuityType::G1 as i32));
if !(at_bound && open) {
continue;
}
// the tangent on the interval's side of the end: at a knot where the curve
// turns, the other side's tangent says nothing about the interval. At a knot
// the evaluation's flag picks the span that starts there (`true`) or the one
// that ends there (`false`).
let (end, tangent) = match curve.evaluate(k, parameter, 1, is_start, true) {
Ok(v) => match v.as_slice() {
[point, tangent, ..] => (*point, *tangent),
_ => continue,
},
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Err(_) => continue,
};
let outward = if is_start { -tangent } else { tangent };
if !end_is_projected_extremum(end, outward, test_pnt, normal, op) {
continue;
}
let value =
match proj_dist(k, curve, parameter, test_pnt, normal, true, Vec3::ZERO, op) {
Ok(d) => d,
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Err(_) => continue,
};
let mut at_end: Solution<O> = Solution::new();
at_end.solution_type = SolutionType::SingleValue;
at_end.nodes[0] = branch[0];
at_end.nodes[1] = branch[1];
at_end.num_variables = 1;
at_end.start.solution_value = value;
at_end.start.parameters[0] = parameter;
at_end.num_objects = 1;
at_end.objects[0] = curve_obj;
self.add_solution(&at_end, true)?;
}
}
// arrive here when bezier segment is not zeroLength
// get closest point on endPoint lineSegment to testPoint
let (ang_deg_from_perp, t) =
@@ -1994,35 +1931,6 @@ impl Curve {
}
}
/// Whether an end of the interval is where the projected distance from `test_point` to the
/// curve is smallest (`ProjectedMinimize`) or largest (`ProjectedMaximize`) near that end:
/// `end` is the curve point there, `outward` its tangent pointing out of the interval and
/// `normal` the projection direction. The distance must change at the end, by more than
/// the angular zero: an end where it is stationary, or where the point projects onto the
/// end, or with no tangent in the projection plane, is `false` (#13658).
fn end_is_projected_extremum(
end: Point3,
outward: Vec3,
test_point: Point3,
normal: Vec3,
op: SolverOperation,
) -> bool {
let offset = normal.cross(end - test_point).cross(normal);
let outward = normal.cross(outward).cross(normal);
let scale = offset.length() * outward.length();
if !scale.is_finite() || offset.length() < EFF_ZERO || outward.length() < EFF_ZERO {
return false;
}
// the cosine between the offset and the outward tangent: negative when the distance
// grows into the interval, positive when it shrinks
let cosine = offset.dot(outward) / scale;
if op == SolverOperation::ProjectedMaximize {
cosine > EFF_ZERO_RAD
} else {
cosine < -EFF_ZERO_RAD
}
}
// ---- TessellateByBisection / Tessellate ---------------------------------------------
impl Curve {
@@ -2522,208 +2430,6 @@ mod tests {
assert_eq!(sols.len(), 1);
}
/// The projected answers `(parameter, value)` of `op` for `point` seen along Z.
fn projected_answers(
curve: &Curve,
interval: (f64, f64),
op: SolverOperation,
point: Vec3,
tolerance: f64,
requested: SolutionRequested,
) -> Vec<(f64, f64)> {
let options = GlobalPointSolveOptions {
opt_vectors: Some(&[Vec3::Z]),
..Default::default()
};
let interval = Extent1d::from_min_max(interval.0, interval.1);
let answers = curve
.global_point_solve::<Obj>(
&Kernel::default(),
&interval,
op,
point,
tolerance,
requested,
&options,
)
.unwrap();
answers
.as_slice()
.iter()
.map(|s| (s.start.parameters[0], s.start.solution_value))
.collect()
}
/// #13658: the nearest (farthest) point seen along a direction is an end of the
/// interval when the distance still falls (rises) there. The line runs from x = -1
/// to x = 1 over [0, 1]; the expected values are plain Pythagoras.
#[test]
fn projected_extremum_at_an_end_of_the_interval_is_a_solution() {
use SolutionRequested::{All, Single};
use SolverOperation::{ProjectedMaximize, ProjectedMinimize};
let line = Curve::from(
BSplineCurve::create_line_segment(3, Vec3::new(-1.0, 0.0, 0.0), Vec3::X).unwrap(),
);
let whole = (0.0, 1.0);
let close = |got: &[(f64, f64)], want: &[(f64, f64)]| {
got.len() == want.len()
&& got
.iter()
.zip(want)
.all(|(g, w)| (g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9)
};
// beyond either end, whatever the height along the direction: the near end
for (point, want) in [
(Vec3::new(2.0, 0.5, 0.0), (1.0, 1.25_f64.sqrt())),
(Vec3::new(-3.0, 1.0, 5.0), (0.0, 5.0_f64.sqrt())),
] {
for requested in [Single, All] {
let got =
projected_answers(&line, whole, ProjectedMinimize, point, 1e-6, requested);
assert!(close(&got, &[want]), "{point:?} {requested:?}: {got:?}");
}
}
// the ends of a part of the line count the same way
let got = projected_answers(
&line,
(0.25, 0.75),
ProjectedMinimize,
Vec3::new(2.0, 0.5, 0.0),
1e-6,
Single,
);
assert!(close(&got, &[(0.75, 2.5_f64.sqrt())]), "{got:?}");
// a foot inside the line is the one minimum however wide the tolerance: the
// ends are the farthest points, not near ones
let above = Vec3::new(0.25, 0.5, 0.0);
for (tolerance, requested) in [(1e-6, Single), (1e-6, All), (50.0, All)] {
let got =
projected_answers(&line, whole, ProjectedMinimize, above, tolerance, requested);
assert!(
close(&got, &[(0.625, 0.5)]),
"{tolerance} {requested:?}: {got:?}"
);
}
// the farthest point is the far end; with a wide tolerance both ends, and never
// the foot
let far = (0.0, 1.8125_f64.sqrt());
let near = (1.0, 0.8125_f64.sqrt());
let got = projected_answers(&line, whole, ProjectedMaximize, above, 1e-6, Single);
assert!(close(&got, &[far]), "{got:?}");
let mut got = projected_answers(&line, whole, ProjectedMaximize, above, 50.0, All);
got.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(close(&got, &[far, near]), "{got:?}");
}
/// An interval that starts at a corner of the curve: the tangent that says whether the
/// start is the nearest point is the one inside the interval. The polyline runs
/// (-1, 0) to (0, 0) to (0, 1) with its corner at 0.5; the interval [0.5, 1] is the
/// second leg, along +y.
#[test]
fn projected_extremum_at_an_interval_starting_at_a_corner_uses_the_inside_tangent() {
use crate::nurbs::crv_basic::Curve as Net;
use crate::nurbs::math::{CPoint, NOW};
use SolutionRequested::{All, Single};
let controls = vec![
CPoint::new(-1.0, 0.0, 0.0, NOW),
CPoint::new(0.0, 0.0, 0.0, NOW),
CPoint::new(0.0, 1.0, 0.0, NOW),
];
let net = Net::new(controls, 1, vec![0.0, 0.0, 0.5, 1.0, 1.0]).unwrap();
let corner = Curve::from(BSplineCurve::from_nurb(net, 3, false));
let leg = (0.5, 1.0);
let op = SolverOperation::ProjectedMinimize;
// below the leg: the corner is the nearest point, although the first leg's
// tangent there points towards the query point
let below = Vec3::new(0.3, -1.0, 0.0);
for requested in [Single, All] {
let got = projected_answers(&corner, leg, op, below, 1e-6, requested);
assert_eq!(got.len(), 1, "{requested:?}: {got:?}");
assert!((got[0].0 - 0.5).abs() < 1e-12, "{got:?}");
assert!((got[0].1 - 1.09_f64.sqrt()).abs() < 1e-12, "{got:?}");
}
// beside the leg: the foot (0, 0.5) is the one minimum; the corner is farther
// and the distance falls from it into the interval
let beside = Vec3::new(-0.5, 0.5, 0.0);
for (tolerance, requested) in [(1e-6, Single), (50.0, All)] {
let got = projected_answers(&corner, leg, op, beside, tolerance, requested);
assert_eq!(got.len(), 1, "{tolerance} {requested:?}: {got:?}");
assert!((got[0].0 - 0.75).abs() < 1e-9, "{got:?}");
assert!((got[0].1 - 0.5).abs() < 1e-9, "{got:?}");
}
// the same for an interval that ends at the corner: the first leg, along +x.
// Right of the corner the corner is nearest; above the leg its foot is.
let first = (0.0, 0.5);
let right = Vec3::new(1.0, 0.3, 0.0);
let got = projected_answers(&corner, first, op, right, 1e-6, Single);
assert_eq!(got.len(), 1, "{got:?}");
assert!((got[0].0 - 0.5).abs() < 1e-6, "{got:?}");
assert!((got[0].1 - 1.09_f64.sqrt()).abs() < 1e-9, "{got:?}");
let above = Vec3::new(-0.5, 0.5, 0.0);
for (tolerance, requested) in [(1e-6, Single), (50.0, All)] {
let got = projected_answers(&corner, first, op, above, tolerance, requested);
assert_eq!(got.len(), 1, "{tolerance} {requested:?}: {got:?}");
assert!((got[0].0 - 0.25).abs() < 1e-9, "{got:?}");
assert!((got[0].1 - 0.5).abs() < 1e-9, "{got:?}");
}
}
/// #13677's curve: a straight projected spline with a knot at 0.5, seen from the
/// origin. The squared distance is 1 + ((t - 0.5) / 1536)^2 on [0.5, 2] and falls
/// on [0, 0.5], so the two ends are the only maxima.
#[test]
fn projected_maximum_of_a_straight_spline_is_at_its_ends() {
use crate::curve::projected_curve::{OwnsCurve, ProjectedCurve};
use crate::nurbs::b_spline_curve::ProjectionType;
use crate::nurbs::crv_basic::Curve as Net;
use crate::nurbs::math::{CPoint, NOW};
let a = 1.0 / 1024.0;
let controls = vec![
CPoint::new(-a, 1.0, 0.0, NOW),
CPoint::new(0.0, 1.0, 0.0, NOW),
CPoint::new(a, 1.0, 0.0, NOW),
];
let net = Net::new(controls, 1, vec![0.0, 0.0, 0.5, 2.0, 2.0]).unwrap();
let base = Curve::from(BSplineCurve::from_nurb(net, 3, false));
let curve = Curve::from(
ProjectedCurve::new(
base,
Some(Vec3::ZERO),
Some(Vec3::Z),
None,
ProjectionType::Parallel,
OwnsCurve::Yes,
)
.unwrap(),
);
let value = (1.0 + a * a).sqrt();
// 1.0 is the tolerance of #13677: wide enough to hold every point of the curve
for (tolerance, requested) in [
(1e-9, SolutionRequested::Single),
(1e-9, SolutionRequested::All),
(1.0, SolutionRequested::All),
] {
let mut got = projected_answers(
&curve,
(0.0, 2.0),
SolverOperation::ProjectedMaximize,
Vec3::ZERO,
tolerance,
requested,
);
got.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(!got.is_empty(), "{tolerance} {requested:?}");
assert!(
got.iter()
.all(|(t, v)| (*t == 0.0 || *t == 2.0) && (v - value).abs() < 1e-12),
"{tolerance} {requested:?}: {got:?}"
);
if requested == SolutionRequested::All {
assert_eq!(got.len(), 2, "{tolerance}: {got:?}");
}
}
}
#[test]
fn point_solution_range_is_degenerate() {
let k = &Kernel::default();
+1 -1
View File
@@ -427,7 +427,7 @@ pub fn record_of(k: &Kernel, c: &Curve, interval: &Extent1d) -> Result<RecordCur
/// The B-spline a curve is (every kernel line, conic and spline is one, its NURB made on a
/// copy where it has none yet); `None` for the curves that are not.
pub(crate) fn b_spline_part<'c>(k: &Kernel, c: &'c Curve) -> Result<Option<Cow<'c, BSplineCurve>>> {
fn b_spline_part<'c>(k: &Kernel, c: &'c Curve) -> Result<Option<Cow<'c, BSplineCurve>>> {
use crate::curve::curve::CurveKind;
Ok(Some(match c.kind() {
CurveKind::BSpline(b) => Cow::Borrowed(b),
+4 -5
View File
@@ -187,11 +187,11 @@ impl RadialGlue {
}
}
let axis = self.axis.unitize()?;
let start = Self::into_face(k, b, first)?.project_to_plane(axis);
let start = Self::leaving(k, b, first)?.project_to_plane(axis);
let side = axis.cross(start);
// the angle round the axis from the first face to a face
let angle = |k: &mut Kernel, b: &mut Brep, at: (EdgeId, f64)| -> Result<f64> {
let w = Self::into_face(k, b, at)?.project_to_plane(axis);
let w = Self::leaving(k, b, at)?.project_to_plane(axis);
let a = w.dot(side).atan2(w.dot(start));
Ok(if a < 0.0 { a + 2.0 * PI } else { a })
};
@@ -268,9 +268,8 @@ impl RadialGlue {
/// The direction a lamina edge's face leaves the edge at a curve parameter: the
/// binormal of the edge's first edge use, which points into the face whichever face
/// use it is in. Not the direction an edge leaves a vertex in
/// ([`crate::query::edge_turn::leaving`]): this one is across the edge, into its face.
fn into_face(k: &Kernel, b: &mut Brep, (e, t): (EdgeId, f64)) -> Result<Vec3> {
/// use it is in.
fn leaving(k: &Kernel, b: &mut Brep, (e, t): (EdgeId, f64)) -> Result<Vec3> {
let eu = edge::edgeuses(b, e, None, None)?
.first()
.copied()
-1
View File
@@ -16,4 +16,3 @@ pub mod surface_intersector;
pub mod surface_silhouette;
pub mod surface_tracer;
pub mod topology_solver;
pub mod views;
@@ -7,7 +7,6 @@
//! |-------------------------------------------------------|--------------------------------|
//! | [`intersect_curves`], [`CurveIntersections`] | `SmApiIntersectCurves` |
//! | [`intersect_surfaces`] | `SmApiIntersectSurfaces` |
//! | [`intersect_surfaces_within`] | (not upstream) |
//! | [`intersect_faces`], [`FaceBodies`], [`Intersections`] | `SmApiIntersectFaces` |
//! | [`intersect_breps`], [`BrepOperands`] | `SmApiIntersectBreps` |
//! | [`intersect_curve_surface`], [`CurveHits`] | `SmApiIntersectCurveSurface` |
@@ -70,9 +69,6 @@ use crate::topology::FaceId;
const OP: &str = "intersect::api_intersectors::intersect_brep_with_plane";
const OP_CURVES: &str = "intersect::api_intersectors::intersect_curves";
const OP_SURFACES: &str = "intersect::api_intersectors::intersect_surfaces";
/// The phrase of [`intersect_surfaces`]'s failure when the surfaces do not meet at all.
pub(crate) const NO_SURFACE_INTERSECTION: &str = "the surfaces do not intersect";
const OP_BREPS: &str = "intersect::api_intersectors::intersect_breps";
const OP_CURVE_SURFACE: &str = "intersect::api_intersectors::intersect_curve_surface";
const OP_CURVE_FACE: &str = "intersect::api_intersectors::intersect_curve_face";
@@ -181,23 +177,6 @@ pub fn intersect_surfaces(
k: &Kernel,
surface1: &Surface,
surface2: &Surface,
) -> (Vec<Curve>, Result<()>) {
intersect_surfaces_within(k, surface1, surface2, None)
}
/// [`intersect_surfaces`] traced to `approx_tol_3d` (`GlobalSurfaceIntersect`'s
/// approximation tolerance; `None`: the intersector's default, a thousandth of the size
/// of the surfaces' domains, which on large surfaces is far coarser than the kernel's
/// tolerances). Not upstream: `SmApiIntersectSurfaces` takes no tolerance.
///
/// # Errors
///
/// In the status: those of [`intersect_surfaces`].
pub fn intersect_surfaces_within(
k: &Kernel,
surface1: &Surface,
surface2: &Surface,
approx_tol_3d: Option<f64>,
) -> (Vec<Curve>, Result<()>) {
let domains = surface1
.natural_uv_domain_with_nurb(k)
@@ -212,7 +191,7 @@ pub fn intersect_surfaces_within(
surface2,
&domain2,
[true, true],
approx_tol_3d,
None,
None,
);
// check result
@@ -220,7 +199,7 @@ pub fn intersect_surfaces_within(
if result.curves_3d.is_empty() {
Err(SolidError::intersect_failed(
OP_SURFACES,
NO_SURFACE_INTERSECTION,
"the surfaces do not intersect",
))
} else {
Ok(())
-395
View File
@@ -1,395 +0,0 @@
//! A curve from two views: the 3D curves seen as one curve along one direction and as another
//! along a second, where the two curves' extrusions along their directions meet.
//!
//! Upstream: none.
//!
//! | Rust | Upstream |
//! |-------------------------|-----------------|
//! | [`curves_from_views`] | (not upstream) |
//! | [`views_do_not_meet`] | (not upstream) |
//!
//! Each curve is extruded along its unit direction both ways from itself, far enough that no
//! meeting point is left out, and the two extrusions are intersected
//! ([`intersect_surfaces_within`]) at the kernel's default approximation tolerance
//! ([`approx_tol3d`] of [`USE_DEFAULT`]: half its zone tolerance). The intersector's own default,
//! a thousandth of the surfaces' size, is not taken: these sheets reach far past the curves, so
//! at model scale it is a thousand times coarser than the curves need.
//!
//! How far is a bound, not a search: a point `X` seen on both curves is
//! `X = p + s â = q + t b̂` with `p` on the first curve and `q` on the second. Its component
//! across `b̂` gives `s (â - (â·b̂) b̂) = (q - p) - ((q - p)·b̂) b̂`, so `|s| sin θ <= |q - p| <= D`,
//! with `θ` the angle between the directions and `D` the diagonal of the box around both
//! curves' control polygons (a NURBS curve with positive weights lies in the convex hull of its
//! control points, so in that box); `|t|` likewise. Each extrusion
//! reaches [`REACH_MARGIN`] times `D / sin θ` on either side, so the meeting curves end on the
//! curves' own ends, never on an extrusion's far edge.
//!
//! Deviations: none (no upstream counterpart). No recursion.
#![allow(clippy::neg_cmp_op_on_partial_ord)] // NaN-refusing tests
use crate::curve::curve::Curve;
use crate::curve::from_parameters::b_spline_part;
use crate::error::intersect::IntersectFailure;
use crate::error::{Result, SolidError};
use crate::geom::tol::{approx_tol3d, USE_DEFAULT};
use crate::geom::vector3d::Vec3;
use crate::intersect::api_intersectors::{intersect_surfaces_within, NO_SURFACE_INTERSECTION};
use crate::kernel::Kernel;
use crate::math::EFF_ZERO_RAD;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::crv_basic::Curve as NurbCurve;
use crate::nurbs::geom::MinMaxBox;
use crate::surface::surface::Surface;
use crate::sweep::api_surfaces::create_extrude;
const OP: &str = "intersect::views::curves_from_views";
/// The phrase of [`curves_from_views`]'s failure when the two views have no point in common
/// (read with [`views_do_not_meet`]).
pub const NO_MEETING: &str = "the views do not meet";
/// How many times the bound on a meeting point's distance from its curve each extrusion
/// reaches on either side of the curve: past the bound by as much again, so that no meeting
/// curve ends on an extrusion's far edge.
pub const REACH_MARGIN: f64 = 2.0;
/// The 3D curves seen as `a` along `a_dir` and as `b` along `b_dir`: where `a` extruded along
/// `a_dir` meets `b` extruded along `b_dir`, both ways from each curve (the module docs give
/// how far). Each curve is returned as a B-spline, as the intersector traces it to the kernel's
/// default approximation tolerance ([`approx_tol3d`] of [`USE_DEFAULT`]) of both extrusions: each
/// of its spans checked at interior samples against both (docs/ssi.md, §4.3), not certified.
/// The directions need not be
/// unit vectors, nor the curves planar: a curve that is not flat across its direction is seen
/// as its shadow along it.
///
/// # Errors
///
/// `InvalidInput` for a direction not finite or shorter than the kernel's zero, directions
/// within [`EFF_ZERO_RAD`] of parallel (either way), or curves whose box has no size or one
/// not finite; [`crate::SolidError::Failed`] with [`IntersectFailure::Failed`] of
/// [`NO_MEETING`] when the extrusions do not meet ([`views_do_not_meet`]); `Unsupported` for a
/// meeting curve with no B-spline form; those of the extrusions and of
/// [`intersect_surfaces_within`].
pub fn curves_from_views(
k: &Kernel,
a: &NurbCurve,
a_dir: Vec3,
b: &NurbCurve,
b_dir: Vec3,
) -> Result<Vec<Curve>> {
let finite = |v: Vec3| v.x.is_finite() && v.y.is_finite() && v.z.is_finite();
if !finite(a_dir) || !finite(b_dir) {
return Err(SolidError::invalid_input(OP, "a direction not finite"));
}
let (a_unit, b_unit) = (a_dir.unitize()?, b_dir.unitize()?);
let sine = a_unit.cross(b_unit).length();
if !(sine >= EFF_ZERO_RAD) {
return Err(SolidError::invalid_input(OP, "the directions are parallel"));
}
let span = union(&a.bbox()?, &b.bbox()?).diagonal();
if !(span.is_finite() && span > 0.0) {
return Err(SolidError::invalid_input(
OP,
"the curves' box has no size or is not finite",
));
}
let reach = REACH_MARGIN * span / sine;
if !reach.is_finite() {
return Err(SolidError::invalid_input(
OP,
"the extrusions' reach is not finite",
));
}
let a_sweep = both_ways(a, a_unit, reach)?;
let b_sweep = both_ways(b, b_unit, reach)?;
let tol = approx_tol3d(k, USE_DEFAULT);
let (curves, status) = intersect_surfaces_within(k, &a_sweep, &b_sweep, Some(tol));
match status {
Ok(()) => curves.iter().map(|c| spline(k, c)).collect(),
Err(e)
if curves.is_empty()
&& e.intersect_failure()
== Some(&IntersectFailure::Failed(NO_SURFACE_INTERSECTION)) =>
{
Err(SolidError::intersect_failed(OP, NO_MEETING))
}
Err(e) => Err(e),
}
}
/// Whether `e` is [`curves_from_views`]'s refusal of two views that do not meet.
#[must_use]
pub fn views_do_not_meet(e: &SolidError) -> bool {
e.intersect_failure() == Some(&IntersectFailure::Failed(NO_MEETING))
}
/// The smallest box holding both boxes.
fn union(a: &MinMaxBox, b: &MinMaxBox) -> MinMaxBox {
MinMaxBox::new(
a.xl.min(b.xl),
a.xr.max(b.xr),
a.yb.min(b.yb),
a.yt.max(b.yt),
a.zn.min(b.zn),
a.zf.max(b.zf),
)
}
/// `curve` extruded along the unit vector `unit` from `-reach` to `reach`: a copy moved back by
/// `reach`, swept forward by twice it ([`create_extrude`]: linear in u, the curve's parameter
/// in v).
fn both_ways(curve: &NurbCurve, unit: Vec3, reach: f64) -> Result<Surface> {
let mut start = curve.clone();
start.translate(unit * -reach);
let mut start = BSplineCurve::from_nurb(start, 3, false);
Ok(Surface::from(create_extrude(
&mut start,
unit * (2.0 * reach),
)?))
}
/// The meeting curve `c` as a B-spline ([`b_spline_part`]: a traced curve is one already, a
/// line made of its two points).
fn spline(k: &Kernel, c: &Curve) -> Result<Curve> {
let base = b_spline_part(k, c)?.ok_or(SolidError::Unsupported {
operation: OP,
reason: "a meeting curve with no B-spline form",
})?;
Ok(Curve::from(base.into_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nurbs::knots::Side;
use crate::nurbs::math::{CPoint, NOW};
/// The cubic Bézier curve on `points`.
fn bezier(points: [[f64; 3]; 4]) -> NurbCurve {
let pw = points
.iter()
.map(|&[x, y, z]| CPoint::new(x, y, z, NOW))
.collect();
NurbCurve::new(pw, 3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).unwrap()
}
/// The side view, z = f(x) in the plane y = 0, its control points evenly spaced in x so
/// that x = 9 t; its slope |dz/dx| is at most 1 (that of its control polygon).
fn side() -> NurbCurve {
bezier([
[0.0, 0.0, 0.0],
[3.0, 0.0, 2.0],
[6.0, 0.0, -1.0],
[9.0, 0.0, 1.0],
])
}
/// The plan view, y = g(x) in the plane z = 0, x = -3 + 15 t (wider than the side view, so
/// the curve seen in both ends on the side view's ends); |dy/dx| at most 0.6.
fn plan(shift: f64) -> NurbCurve {
bezier([
[shift - 3.0, 1.0, 0.0],
[shift + 2.0, 4.0, 0.0],
[shift + 7.0, 2.0, 0.0],
[shift + 12.0, 5.0, 0.0],
])
}
#[test]
fn a_side_and_a_plan_view_meet_in_the_curve_seen_in_both() {
let k = Kernel::default();
let tol = approx_tol3d(&k, USE_DEFAULT);
let (a, b) = (side(), plan(0.0));
let curves = curves_from_views(
&k,
&a,
Vec3::new(0.0, 1.0, 0.0),
&b,
Vec3::new(0.0, 0.0, 3.0),
)
.unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let iv = c.natural_interval();
for i in 0..=64 {
let s = iv.min() + (iv.max() - iv.min()) * f64::from(i) / 64.0;
let x = c.evaluate_point(&k, s).unwrap();
assert!(
(-tol..=9.0 + tol).contains(&x.x),
"past the side view: {x:?}"
);
(lo, hi) = (lo.min(x.x), hi.max(x.x));
// On the side view's extrusion along y: its height there is the side view's.
// A gap measured along z is at most the distance times sqrt(1 + slope²),
// below 1 + slope, the slope at most 1.
let on_a = a.eval((x.x / 9.0).clamp(0.0, 1.0), Side::Left).unwrap();
assert!((on_a.z - x.z).abs() <= 2.0 * tol, "{x:?} against {on_a:?}");
// On the plan view's extrusion along z, the slope at most 0.6.
let on_b = b.eval((x.x + 3.0) / 15.0, Side::Left).unwrap();
assert!((on_b.y - x.y).abs() <= 1.6 * tol, "{x:?} against {on_b:?}");
}
}
// The whole of the side view's run is seen.
assert!(lo <= tol && hi >= 9.0 - tol, "from {lo} to {hi}");
}
/// A side view in y = 0 (seen along y) and a plan view in z = 0 (seen along z) at model
/// scale, about 170 mm across: quadratic Bézier curves, both increasing in x.
const SIDE_Q: [[f64; 3]; 3] = [
[-92.06693847750134, 0.0, -39.76423812596792],
[0.0, 0.0, 18.451591211372346],
[73.55298836314594, 0.0, 15.024361734430357],
];
const PLAN_Q: [[f64; 3]; 3] = [
[-16.758544054811637, -82.27159357644084, 0.0],
[42.459806193714414, -83.42331426400659, 0.0],
[76.37870944288886, -34.86770213654586, 0.0],
];
/// The quadratic Bézier curve on `q`.
fn quadratic(q: [[f64; 3]; 3]) -> NurbCurve {
let pw = q
.iter()
.map(|&[x, y, z]| CPoint::new(x, y, z, NOW))
.collect();
NurbCurve::new(pw, 2, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]).unwrap()
}
/// The point of the quadratic Bézier curve `q` at `t`, in Bernstein's form (none of the
/// kernel's evaluators).
fn bernstein(q: &[[f64; 3]; 3], t: f64) -> [f64; 3] {
let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t);
[0, 1, 2].map(|i| b0 * q[0][i] + b1 * q[1][i] + b2 * q[2][i])
}
/// Its derivative at `t`.
fn bernstein_d(q: &[[f64; 3]; 3], t: f64) -> [f64; 3] {
[0, 1, 2].map(|i| 2.0 * (1.0 - t) * (q[1][i] - q[0][i]) + 2.0 * t * (q[2][i] - q[1][i]))
}
/// The parameter of `q` (increasing in x) where its x is `x`, by bisection on [0, 1].
fn at_x(q: &[[f64; 3]; 3], x: f64) -> f64 {
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..100 {
let m = 0.5 * (lo + hi);
if bernstein(q, m)[0] < x {
lo = m;
} else {
hi = m;
}
}
0.5 * (lo + hi)
}
/// The point at `t` of the B-spline with the full knot vector `knots` and the homogeneous
/// control points `pw`, by de Boor's algorithm (none of the kernel's evaluators).
fn de_boor(knots: &[f64], pw: &[[f64; 4]], t: f64) -> [f64; 3] {
let n = pw.len();
let p = knots.len() - n - 1;
let k = (p..n).rev().find(|&k| knots[k] <= t).unwrap_or(p);
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| pw[j + k - p]).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let (lo, hi) = (knots[j + k - p], knots[j + 1 + k - r]);
let alpha = (t - lo) / (hi - lo);
d[j] = [0, 1, 2, 3].map(|i| (1.0 - alpha) * d[j - 1][i] + alpha * d[j][i]);
}
}
let h = d[p];
[h[0] / h[3], h[1] / h[3], h[2] / h[3]]
}
/// The full knot vector and homogeneous control points of a meeting curve (a B-spline).
fn net(c: &Curve) -> (Vec<f64>, Vec<[f64; 4]>) {
let crate::curve::curve::CurveKind::BSpline(b) = c.kind() else {
panic!("a B-spline: {c:?}")
};
let can = b.canonical().unwrap();
let knots = can
.knots
.iter()
.zip(&can.knot_multiplicities)
.flat_map(|(k, m)| std::iter::repeat_n(*k, *m))
.collect();
let pw = can
.control_points
.iter()
.enumerate()
.map(|(i, p)| {
let w = can.weights.get(i).copied().unwrap_or(1.0);
[p.x * w, p.y * w, p.z * w, w]
})
.collect();
(knots, pw)
}
#[test]
fn views_at_model_scale_meet_within_the_tolerance() {
// The intersector's own default tolerance, a thousandth of these sheets' size, left
// this curve 5.75e-3 off its views; it is traced to the kernel's tolerance.
let k = Kernel::default();
let tol = approx_tol3d(&k, USE_DEFAULT);
let (a, b) = (quadratic(SIDE_Q), quadratic(PLAN_Q));
let curves = curves_from_views(&k, &a, Vec3::Y, &b, Vec3::Z).unwrap();
assert!(!curves.is_empty());
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &curves {
let (knots, pw) = net(c);
let p = knots.len() - pw.len() - 1;
let (t0, t1) = (knots[p], knots[pw.len()]);
for i in 0..=512 {
let x = de_boor(&knots, &pw, t0 + (t1 - t0) * f64::from(i) / 512.0);
(lo, hi) = (lo.min(x[0]), hi.max(x[0]));
// A gap along the view's other axis is at most the distance times
// sqrt(1 + slope²), below 1 + slope; twice the tolerance, as the intersector
// checks its spans at interior samples.
let s = at_x(&SIDE_Q, x[0]);
let (q, d) = (bernstein(&SIDE_Q, s), bernstein_d(&SIDE_Q, s));
let allowed = 2.0 * tol * (1.0 + (d[2] / d[0]).abs());
assert!(
(q[2] - x[2]).abs() <= allowed,
"off the side view: {x:?} against {q:?}"
);
let s = at_x(&PLAN_Q, x[0]);
let (q, d) = (bernstein(&PLAN_Q, s), bernstein_d(&PLAN_Q, s));
let allowed = 2.0 * tol * (1.0 + (d[1] / d[0]).abs());
assert!(
(q[1] - x[1]).abs() <= allowed,
"off the plan view: {x:?} against {q:?}"
);
}
}
// The overlap of the two runs is seen: from the plan view's start to the side view's end.
assert!(
(lo - PLAN_Q[0][0]).abs() <= 2.0 * tol && (hi - SIDE_Q[2][0]).abs() <= 2.0 * tol,
"from {lo} to {hi}"
);
}
#[test]
fn parallel_or_zero_directions_are_refused() {
let k = Kernel::default();
let (a, b) = (side(), plan(0.0));
let y = Vec3::new(0.0, 1.0, 0.0);
for (da, db) in [
(y, Vec3::new(0.0, -2.0, 0.0)),
(y, y),
(Vec3::ZERO, Vec3::Z),
(y, Vec3::new(f64::NAN, 0.0, 1.0)),
] {
let e = curves_from_views(&k, &a, da, &b, db).unwrap_err();
assert!(matches!(e, SolidError::InvalidInput { .. }), "{e:?}");
assert!(!views_do_not_meet(&e));
}
}
#[test]
fn views_whose_runs_do_not_overlap_do_not_meet() {
let k = Kernel::default();
// The plan view moved to x in [20, 35], past the side view's [0, 9].
let e = curves_from_views(&k, &side(), Vec3::Y, &plan(23.0), Vec3::Z).unwrap_err();
assert!(views_do_not_meet(&e), "{e:?}");
}
}
@@ -106,8 +106,7 @@ impl SectionPlanes {
}
let mut planes = Vec::new();
for &face in faces {
let k = a.kernel(face.0)?;
match face_surface(a, face)?.kind_with_nurb(k)? {
match face_surface(a, face)?.kind() {
SurfaceKind::Plane(p) => {
let (point, normal, _) = p.is_planar();
planes.push((point, normal));
@@ -436,18 +435,30 @@ impl PrimitiveCreation {
// Not upstream: sections on parallel planes whose chord-length skin leaves the slab
// between the first and last planes are skinned at their planes' distances, every
// layer alike (`SectionPlanes`).
if let Some(planes) = SectionPlanes::of(&*a, faces)? {
// A failure of the check or of the second build keeps the chord-length skins, as
// upstream builds them; only cancellation and unported calls stop the loft.
let replanned = (|| -> Result<Option<Vec<BSplineSurface>>> {
let Some(planes) = SectionPlanes::of(&*a, faces)? else {
return Ok(None);
};
let tol = a.brep(brep)?.tolerance();
let mut leaves = false;
for s in &surfaces {
leaves = leaves || planes.leaves(s, tol)?;
}
if leaves {
surfaces.clear();
for curves in &layer_curves {
surfaces.push(skin(a.kernel(brep)?, curves, Some(planes.params()))?);
}
if !leaves {
return Ok(None);
}
let mut again = Vec::new();
for curves in &layer_curves {
again.push(skin(a.kernel(brep)?, curves, Some(planes.params()))?);
}
Ok(Some(again))
})();
match replanned {
Ok(Some(again)) => surfaces = again,
Err(e) if e.is_unported_or_cancelled() => return Err(e),
Ok(None) | Err(_) => {}
}
let mut traced: Vec<(FaceId, Vec<usize>)> = Vec::new();
let mut made = Vec::new();
+26 -120
View File
@@ -6,18 +6,15 @@
//!
//! | Rust | Upstream |
//! |---------------|----------|
//! | [`leaving`], [`leaving_derivative`] | (none) |
//! | [`leaving`] | (none) |
//! | [`turn_at`] | (none) |
//! | [`convex_at`] | (none) |
//!
//! Deviations: no upstream counterpart.
use crate::blend::fillet_section::interval::Ivl;
use crate::error::{Result, SolidError};
use crate::kernel::Kernel;
use crate::math::Vec3;
use crate::nurbs::math::UNIT;
use crate::query::tangent_edge::{cross_ivl, dot_ivl, face_use_normal, norm_ivl};
use crate::topology::edgeuse::p1 as eu1;
use crate::topology::edgeuse::p3::{evaluate_binormal, BinormalOptions};
use crate::topology::{edge, Brep, EdgeId, VertexId};
@@ -46,74 +43,30 @@ fn end_at(b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<(f64, bool)>> {
}
}
/// The derivative of edge `e`'s curve at its start (`at_start`) or its end, taken from inside
/// the edge and turned to point away from that end (not unit): the one evaluation every
/// "direction an edge leaves its vertex in" uses ([`leaving`], the mixed corner's). `None` for
/// an edge without curve.
///
/// Inside the edge is one-sided: `from_left` selects the half-open span `[U_i, U_(i+1))`
/// ([`crate::nurbs::knots::Side::Left`]), the one above a knot, so it is taken at the start
/// and not at the end, where the span below is. Across a knot where the curve kinks, the
/// other side would read the neighbouring segment's tangent.
///
/// # Errors
///
/// Those of the evaluation; [`SolidError::InvalidInput`] for an evaluation without its first
/// derivative.
pub fn leaving_derivative(k: &Kernel, b: &Brep, e: EdgeId, at_start: bool) -> Result<Option<Vec3>> {
match leaving_tangent(k, b, e, at_start)? {
Leaving::NoCurve => Ok(None),
Leaving::NoTangent => Err(invalid("a curve's evaluation without its tangent")),
Leaving::Tangent(d) => Ok(Some(d)),
}
}
/// What [`leaving_derivative`]'s evaluation found, for a caller that names each case in its
/// own refusals (the mixed corner's typed errors).
pub(crate) enum Leaving {
/// The edge has no curve.
NoCurve,
/// The curve's evaluation came back without its first derivative.
NoTangent,
/// The derivative inside the edge, turned away from the vertex.
Tangent(Vec3),
}
/// [`leaving_derivative`], each case named ([`Leaving`]).
///
/// # Errors
///
/// Those of the evaluation.
pub(crate) fn leaving_tangent(k: &Kernel, b: &Brep, e: EdgeId, at_start: bool) -> Result<Leaving> {
let Some(curve) = edge::curve(b, e)? else {
return Ok(Leaving::NoCurve);
};
let ivl = edge::interval(b, e)?;
let t = if at_start { ivl.min() } else { ivl.max() };
let d = curve.evaluate(k, t, 1, at_start, true)?;
let Some(&tangent) = d.get(1) else {
return Ok(Leaving::NoTangent);
};
Ok(Leaving::Tangent(if at_start { tangent } else { -tangent }))
}
/// The unit direction edge `e` leaves its end `v` in: its curve's tangent there from inside
/// the edge ([`leaving_derivative`]), turned to point away from `v`. `None` for a closed edge
/// (which end is meant is not said; the mixed corner takes the start), an edge without curve,
/// or a tangent of no length.
/// The unit direction edge `e` leaves its end `v` in: its curve's tangent there, turned to
/// point away from `v`. `None` for a closed edge, an edge without curve, or a tangent of no
/// length.
///
/// # Errors
///
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
/// evaluation.
pub fn leaving(k: &Kernel, b: &Brep, e: EdgeId, v: VertexId) -> Result<Option<Vec3>> {
let Some((_, at_start)) = end_at(b, e, v)? else {
let Some((t, at_start)) = end_at(b, e, v)? else {
return Ok(None);
};
let Some(d) = leaving_derivative(k, b, e, at_start)? else {
let Some(curve) = edge::curve(b, e)? else {
return Ok(None);
};
Ok(d.unitize().ok())
// from inside the edge: from the right at its start, from the left at its end
let d = curve.evaluate(k, t, 1, !at_start, true)?;
let Some(&tangent) = d.get(1) else {
return Err(invalid("a curve's evaluation without its tangent"));
};
let Ok(unit) = tangent.unitize() else {
return Ok(None);
};
Ok(Some(if at_start { unit } else { unit * -1.0 }))
}
/// How far (radians, 0 to pi) edge `f` turns at their shared end `v` from running straight on
@@ -138,37 +91,16 @@ pub fn turn_at(k: &Kernel, b: &Brep, e: EdgeId, f: EdgeId, v: VertexId) -> Resul
/// the material there): the direction from the edge into its second face, square to it,
/// points along its first face's inward normal (the face use normals of the edge use on the
/// material's side, its face use's region not void, and of its radial partner, which point
/// into the material). `None` for an edge not between two face uses, a closed edge, an edge
/// with no edge use on a material region's side, faces not proved to part by more than
/// `smooth` radians (in `(0, pi / 2)`) there, or a side not proved apart from 0: tangent
/// faces have no convexity. The faces of a stored body are tangent only to the rounding of
/// its pcurves, so the sign of the side alone, past the arithmetic's rounding, can still be
/// that noise; the caller's smooth angle (the one its chain joins edges by) says when the
/// faces count as tangent. So `None` means: tangent within the angle asked, or not
/// decidable. The normals are enclosed (`query::tangent_edge`'s `F`); the
/// edge's tangent is taken as its evaluator gives it, and the evaluator's binormal only fixes
/// the sense of the direction into the second face.
///
/// Two ported convexity tests stay beside it, each with upstream's numerics:
/// [`crate::topology::edgeuse::p2::is_convex_radial_sector`] (`IsConvexRadialSector`) decides
/// a whole edge from samples along it with a degree tolerance, and the blend corner's
/// `continuing_edges` compares convexity by its port's rule. This one is a certified answer
/// at one vertex, and says `None` where the faces are tangent rather than guess a side.
/// into the material). `None` for
/// an edge not between two face uses, a closed edge, an edge with no edge use on a material
/// region's side, or faces whose normal and binormal there are square to each other (tangent
/// faces: no convexity).
///
/// # Errors
///
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e` or a smooth angle out
/// of range; those of the evaluations.
pub fn convex_at(
k: &Kernel,
b: &mut Brep,
e: EdgeId,
v: VertexId,
smooth: f64,
) -> Result<Option<bool>> {
if !(smooth > 0.0 && smooth < std::f64::consts::FRAC_PI_2) {
return Err(invalid("a smooth angle not in (0, pi / 2)"));
}
/// [`SolidError::InvalidInput`] for a vertex that is not an end of `e`; those of the
/// evaluations.
pub fn convex_at(k: &Kernel, b: &mut Brep, e: EdgeId, v: VertexId) -> Result<Option<bool>> {
if !edge::is_manifold(b, e)? || eu1::edge_is_lamina(b, e)? {
return Ok(None);
}
@@ -196,38 +128,12 @@ pub fn convex_at(
let Some(eu_r) = eu1::radial(b, eu0)? else {
return Ok(None);
};
// the two face use normals, enclosed (positive multiples of the unit normals, into the
// material on this side)
let (Some(na), Some(nc)) = (
face_use_normal(k, b, eu0, t)?,
face_use_normal(k, b, eu_r, t)?,
) else {
return Ok(None);
};
// faces within `smooth` of each other: tangent, no convexity. They part by more only where
// a dot product of at most 0 or |na x nc| > tan(smooth) (na . nc) is proved (the tangent
// within 2 ulps, and the product one rounding: 8 u more)
let (c, d) = (norm_ivl(cross_ivl(na, nc)), dot_ivl(na, nc));
let parted = d.hi <= 0.0 || c.lo > smooth.tan() * (1.0 + 8.0 * UNIT) * d.hi;
if !parted {
return Ok(None);
}
// the direction from the edge into the second face, square to it: its normal across the
// edge's tangent, turned to the evaluator's binormal (which only fixes its sense)
let options = BinormalOptions::default();
let first = evaluate_binormal(k, b, eu0, t, false, &options)?;
let second = evaluate_binormal(k, b, eu_r, t, false, &options)?;
let tangent = [second.tangent.x, second.tangent.y, second.tangent.z].map(Ivl::from);
let mut into = cross_ivl(nc, tangent);
let along = into[0].mid() * second.binormal.x
+ into[1].mid() * second.binormal.y
+ into[2].mid() * second.binormal.z;
if along < 0.0 {
into = into.map(|x| Ivl::from(0.0) - x);
}
let side = dot_ivl(into, na);
// tangent faces there: the enclosure holds 0, no convexity
if side.lo <= 0.0 && side.hi >= 0.0 {
let side = second.binormal.dot(first.face_normal);
if side == 0.0 || !side.is_finite() {
return Ok(None);
}
Ok(Some(side.lo > 0.0))
Ok(Some(side > 0.0))
}
File diff suppressed because it is too large Load Diff
+23 -126
View File
@@ -553,11 +553,7 @@ impl Cone {
/// [`Self::create_canonical`] (same `swap_uv` and `inside_out`), its NURB
/// reparameterised to the matching part of this NURB domain (upstream scales the
/// domain maximum, not its span), the generator's NURB interval set to match and
/// the polar converter rebuilt; the matching part is anchored at the end that
/// survives (an inside-out
/// cone's NURB runs the generator backwards, so its ends swap), so every point of
/// the kept cone keeps its parameter and the face's UV trims lift onto the offset
/// unchanged;
/// the polar converter rebuilt;
/// * else a copy with the new base radius and origin, every NURB control point
/// moved by the offset of its generator row (computed from the next control
/// point at the apex) and the generator translated by the offset vector.
@@ -610,22 +606,20 @@ impl Cone {
// only the BotRadius or TopRadius has gone negative
if new_bot_radius < approx_tol_3d || new_top_radius < approx_tol_3d {
// compute BotOrigin, Height, Bot and Top radii for new truncated cone.
// The kept cone runs between its new apex (where the offset radius reaches
// zero) and the surviving end, at the original's slope: the radius spans
// `old_top_radius - old_bot_radius` over `height`.
let (new_height, bot_origin, collapsed_at_base) = if new_bot_radius < approx_tol_3d {
let new_height = new_top_radius * height / old_top_radius;
// compute BotOrigin, Height, Bot and Top radii for new truncated cone
let (new_height, bot_origin) = if new_bot_radius < approx_tol_3d {
let tan_ang = height / old_top_radius;
let new_height = new_top_radius * tan_ang;
new_bot_radius = 0.0;
(
new_height,
origin + (height + off_z_dist - new_height) * z_axis,
true,
)
} else {
let new_height = new_bot_radius * height / old_bot_radius;
let tan_ang = height / old_bot_radius;
let new_height = new_bot_radius * tan_ang;
new_top_radius = 0.0;
(new_height, origin + off_z_dist * z_axis, false)
(new_height, origin + off_z_dist * z_axis)
};
// New Cone Position - truncated at new apex position
@@ -643,37 +637,21 @@ impl Cone {
)?;
// compute the offsetSurf Nurb domain to be a properly trimmed subset of the
// input Nurb domain: the part the truncated cone stands for. Its span is the
// kept height's share of this domain's, anchored at the end that survives,
// so a point of the kept cone keeps its parameter: the offset's rim stays at
// the rim's parameter and the face's UV trims lift onto it unchanged (the
// offset of S is S + d n at the same parameters). An inside-out cone's NURB
// runs the generator backwards (`cone_convert_uv_from_nurbs_to_step`), so the
// surviving end is the domain's other end there.
let at_max_end = collapsed_at_base != self.inside_out;
let tgt_nurb_domain = {
let ratio = new_height / height;
if self.rev.swap_uv {
let lo = nurb_domain.min().x;
let hi = nurb_domain.max().x;
let span = hi - lo;
let (min_x, max_x) = if at_max_end {
(hi - ratio * span, hi)
} else {
(lo, lo + ratio * span)
};
Extent2d::from_bounds(min_x, nurb_domain.min().y, max_x, nurb_domain.max().y)
} else {
let lo = nurb_domain.min().y;
let hi = nurb_domain.max().y;
let span = hi - lo;
let (min_y, max_y) = if at_max_end {
(hi - ratio * span, hi)
} else {
(lo, lo + ratio * span)
};
Extent2d::from_bounds(nurb_domain.min().x, min_y, nurb_domain.max().x, max_y)
}
// input Nurb domain
let tgt_nurb_domain = if self.rev.swap_uv {
Extent2d::from_bounds(
nurb_domain.min().x,
nurb_domain.min().y,
nurb_domain.min().x + new_height / height * nurb_domain.max().x,
nurb_domain.max().y,
)
} else {
Extent2d::from_bounds(
nurb_domain.min().x,
nurb_domain.min().y,
nurb_domain.max().x,
nurb_domain.min().y + new_height / height * nurb_domain.max().y,
)
};
// update the pOffCone NurbDomain (status ignored)
@@ -2111,87 +2089,6 @@ mod tests {
assert!(trunc.top_radius().abs() < 1e-12);
}
/// The offset of a cone truncated at its new apex is reparameterised to the matching
/// part of this NURB domain, anchored at the end that survives (an inside-out cone's
/// NURB runs the generator backwards), so a point of the kept cone keeps its
/// parameter and the face's UV trims lift onto the offset unchanged
/// (tangency-labs/solidcraft#11774: the part sat at the domain's other end, the
/// rim's UV moved, and a shell's edge-use mapping could not pair the rim, so the
/// shell refused a cone with an apex).
#[test]
fn offset_truncation_keeps_the_surviving_ends_parameters() {
let k = kernel();
for inside in [false, true] {
// negated inside out: the shrinking offset is -1.2 there and 1.2 here
let d = if inside { 1.2 } else { -1.2 };
// full apex cones: (bot, top) = (0, 2) collapses at the bot end under the
// offset, (2, 0) at the top
for (bot, top) in [(0.0, 2.0), (2.0, 0.0)] {
let cone = canonical(&k, bot, top, 3.0, false, inside);
let off = cone.create_offset_surface(&k, d, 1e-6).unwrap().unwrap();
// the radii move by cos(semi) * d along the NURB normal (negated
// inside out); the kept cone runs from where that radius reaches zero
// to the surviving end, at the slope (top - bot) / 3
let semi = cone.semi_angle_deg() * PI / 180.0;
let moved = (if inside { -1.0 } else { 1.0 }) * d * semi.cos();
let (new_bot, new_top) = (bot + moved, top + moved);
let collapsed_bot = new_bot < new_top;
let (want_bot, want_top, want_height) = if collapsed_bot {
(0.0, new_top, 3.0 * new_top / (top - bot))
} else {
(new_bot, 0.0, 3.0 * new_bot / (bot - top))
};
assert!(
(off.bot_radius() - want_bot).abs() < 1e-9
&& (off.top_radius() - want_top).abs() < 1e-9,
"bot {bot} top {top} inside {inside}: radii {:?} {:?}",
off.bot_radius(),
off.top_radius()
);
assert!(
(off.height(&k).unwrap() - want_height).abs() < 1e-9,
"bot {bot} top {top} inside {inside}: height {:?}, the radius's span gives {want_height}",
off.height(&k)
);
// The matching part of this NURB domain, anchored at the surviving end:
// the bot sits at the domain's minimum and the top at its maximum, and an
// inside-out cone's NURB runs that generator backwards, so the surviving
// end is the maximum one when the collapsed end is the bot's, and the
// minimum one when the collapsed end is the top's, and the other way
// round inside out.
let at_max_end = collapsed_bot != inside;
let dom0 = cone.rev().base().natural_uv_domain();
let dom = off.rev().base().natural_uv_domain();
let (lo, hi) = (dom0.min().y, dom0.max().y);
let span = hi - lo;
let kept = want_height / 3.0 * span;
let (want_min, want_max) = if at_max_end {
(hi - kept, hi)
} else {
(lo, lo + kept)
};
assert!(
(dom.min().y - want_min).abs() < 1e-9 && (dom.max().y - want_max).abs() < 1e-9,
"bot {bot} top {top} inside {inside}: domain {dom:?}, matching part [{want_min}, {want_max}]"
);
// the offset of S is S + d n at the same parameters: sampled points of
// the kept cone keep their distance from the original
for t in [0.3, 0.7] {
let uv = Point2::new(0.37, want_min + t * (want_max - want_min));
let s = cone.evaluate_point(&k, uv).unwrap();
let o = off.evaluate_point(&k, uv).unwrap();
assert!(
(s.distance(o) - d.abs()).abs() < 1e-9,
"bot {bot} top {top} inside {inside} at {uv:?}: {}",
s.distance(o)
);
}
}
}
}
#[test]
fn offset_with_non_finite_truncation_height_returns() {
// bottom radius 0 and a negative top radius: the truncation height is h / 0
+79
View File
@@ -11,6 +11,7 @@
use crate::error::{Result, SolidError};
use crate::geom::axis2_placement::Axis2Placement;
use crate::geom::extent2d::Extent2d;
use crate::geom::vector2d::Point2;
use crate::kernel::Kernel;
use crate::math::{Vec2, Vec3, PI};
use crate::nurbs::b_spline_curve::BSplineCurve;
@@ -68,6 +69,42 @@ pub enum Implicit {
},
}
impl Implicit {
/// How far `x` is from this surface.
#[must_use]
pub fn distance(&self, x: Vec3) -> f64 {
// the axial and radial parts of `x` about an axis through `o`
let about = |o: Vec3, axis: Vec3| {
let v = x - o;
let h = v.dot(axis);
(h, (v - axis * h).length())
};
match *self {
Self::Plane { origin, normal } => (x - origin).dot(normal).abs(),
Self::Cone {
origin,
axis,
radius,
tan_angle,
} => {
let (h, rho) = about(origin, axis);
(rho - (radius + tan_angle * h)).abs() / (1.0 + tan_angle * tan_angle).sqrt()
}
Self::Sphere { centre, radius } => ((x - centre).length() - radius).abs(),
Self::Torus {
centre,
axis,
major,
minor,
} => {
let (h, rho) = about(centre, axis);
let (a, b) = (rho - major, h);
((a * a + b * b).sqrt() - minor).abs()
}
}
}
}
/// A polynomial in Bernstein form over a patch, degrees `(p, q)`, `c[i (q + 1) + j]`.
#[derive(Debug, Clone, PartialEq)]
struct BPoly {
@@ -407,6 +444,48 @@ pub fn check_exact_net(k: &Kernel, net: &Surface, check: &ExactNetCheck) -> Resu
Ok(())
}
/// The number of intervals [`net_miss`] samples a net at, each way.
const MISS_SAMPLES: u32 = 16;
/// How far `net` strays from the class's `surface`, sampled over its domain. A guard, not a
/// certificate: [`check_exact_net`] is the proof, and a net that passes only this keeps no
/// definition. It tells a net that the class's rounding leaves off the surface from one
/// built wrongly. Being sampled ([`MISS_SAMPLES`] intervals each way), it can miss a
/// deviation between its samples; it bounds nothing, and decides only whether a net its
/// certificate refused is kept bare or is an error.
///
/// # Errors
///
/// Those of the evaluations.
pub fn net_miss(k: &Kernel, net: &Surface, surface: &ClassSurface) -> Result<f64> {
let dom = net.natural_uv_domain_with_nurb(k)?;
let (lo, hi) = (dom.min(), dom.max());
let mut worst = 0.0_f64;
for i in 0..=MISS_SAMPLES {
for j in 0..=MISS_SAMPLES {
let (fu, fv) = (
f64::from(i) / f64::from(MISS_SAMPLES),
f64::from(j) / f64::from(MISS_SAMPLES),
);
let uv = Point2::new(lo.x + (hi.x - lo.x) * fu, lo.y + (hi.y - lo.y) * fv);
let x = net.evaluate_point(k, uv)?;
let miss = match surface {
ClassSurface::Implicit(a) => a.distance(x),
ClassSurface::Canal {
spine, map, radius, ..
} => {
let w = if map.along_v { uv.y } else { uv.x };
let ivl = spine.natural_interval();
let s = (map.a + map.b * w).max(ivl.min()).min(ivl.max());
((x - spine.evaluate_point(s)?).length() - *radius).abs()
}
};
worst = worst.max(miss);
}
}
Ok(worst)
}
/// A Bezier segment of a rational curve, Bernstein polynomials of degree `(p, 0)`: the
/// homogeneous `(Y - O V)` components and the weight `V`.
type CurveSegment = ([BPoly; 3], BPoly);
@@ -40,7 +40,8 @@ use crate::kernel::Kernel;
use crate::math::EFF_ZERO;
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::surface::implicit::{
check_exact_net, lies_on, AnalyticSupport, ClassSurface, ExactNetCheck, Implicit, LeaderMap,
check_exact_net, lies_on, net_miss, AnalyticSupport, ClassSurface, ExactNetCheck, Implicit,
LeaderMap,
};
use crate::surface::procedural_blend::chamfer::ChamferDefinition;
use crate::surface::surface::Surface;
@@ -358,6 +359,28 @@ impl ExactRound {
Ok(0.0)
}
/// How far `net` strays from the surface of the class its supports give
/// ([`net_miss`]): the surface [`Self::recertify`] checks it against (a chord's plane
/// through its contacts, any other class's surface from the supports); `None` where they
/// give it none.
///
/// # Errors
///
/// Those of reading the supports' quadrics and of the evaluations.
pub fn miss(&self, k: &Kernel, net: &Surface) -> Result<Option<f64>> {
let [a, b] = &self.supports;
let blend = classify_supports(k, [a, b], &self.inputs)?;
let surface = if self.class == ExactClass::Chord {
chord_implicit(&blend).map(ClassSurface::Implicit)
} else {
class_surface(&blend, self.leader.as_ref(), &self.map)
};
match surface {
Some(surface) => net_miss(k, net, &surface).map(Some),
None => Ok(None),
}
}
/// A round of the circle class: the torus `net` is ([`own_torus`]: its revolution's
/// axis and generating circle, or its torus label), proved to hold its form to rounding
/// ([`lies_on`]),
-84
View File
@@ -1855,30 +1855,6 @@ pub(crate) fn side_of_curve(
// Project vectors into projection plane
vec = projection_direction.cross(vec).cross(projection_direction);
// (not upstream, #13658) The nearest point of an open curve to a point beyond its
// end is that end, where the line to the point is not square to the curve. The
// projection returned no solution there before it answered at an interval's ends,
// and a point beyond the end of the curve is on neither of its sides, so it stays a
// failure. A closed curve has no such end: its seam is a point like any other.
let interval = curve.natural_interval();
let parameter = sol.start.param(0)?;
let at_end = [interval.min(), interval.max()]
.iter()
.any(|end| (parameter - end).abs() <= EFF_ZERO * (1.0 + end.abs()));
if at_end && !curve.is_closed(k, &interval, 0.0)? {
let along = projection_direction.cross(p1).cross(projection_direction);
let scale = vec.length() * along.length();
if vec.length() >= EFF_ZERO
&& along.length() >= EFF_ZERO
&& (vec.dot(along) / scale).abs() > EFF_ZERO_RAD
{
return Err(SolidError::topology_failed(
OP,
"the point does not project onto the curve exactly once",
));
}
}
// point is on the curve
if vec.length() < tol {
return Ok(SideOfCurve::Undecided("the point is on the curve"));
@@ -4315,66 +4291,6 @@ mod tests {
assert!(find_highest_vote(&rs, &[1]).is_err());
}
/// #13658: a point beside a line has a side; a point beyond either end of it has
/// none, although the line's nearest point to it is now found (that end).
#[test]
fn a_point_beyond_the_end_of_a_curve_has_no_side() {
use crate::nurbs::b_spline_curve::p1::BSplineCurve;
let k = Kernel::default();
let line = Curve::from(
BSplineCurve::create_line_segment(3, Vec3::new(-1.0, 0.0, 0.0), Vec3::X).unwrap(),
);
let side =
|x: f64, y: f64| find_side_of_curve(&k, &line, Vec3::new(x, y, 0.0), Vec3::Z, 1e-6);
assert_eq!(side(0.25, 0.5), Ok(false));
assert_eq!(side(0.25, -0.5), Ok(true));
let not_once = SolidError::topology_failed(
OP,
"the point does not project onto the curve exactly once",
);
for (x, y) in [(2.0, 0.5), (2.0, -0.5), (-3.0, 1.0), (1.5, 0.0)] {
assert_eq!(side(x, y), Err(not_once.clone()), "({x}, {y})");
}
}
/// The side of a point whose nearest point is a corner of a closed curve does not
/// depend on where the curve's seam is. Before #13658 the projection found the
/// corner when it was inside the curve and nothing when it was the seam.
#[test]
fn the_side_of_a_closed_curve_does_not_depend_on_its_seam() {
use crate::nurbs::b_spline_curve::p1::BSplineCurve;
use crate::nurbs::crv_basic::Curve as Net;
use crate::nurbs::math::{CPoint, NOW};
let k = Kernel::default();
let square = |points: [(f64, f64); 5]| {
let controls = points
.iter()
.map(|(x, y)| CPoint::new(*x, *y, 0.0, NOW))
.collect();
let knots = vec![0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0];
Curve::from(BSplineCurve::from_nurb(
Net::new(controls, 1, knots).unwrap(),
3,
false,
))
};
// the unit square, counter-clockwise: its seam at the corner (0, 0), then at (1, 0)
let seam_at_corner = square([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]);
let corner_inside = square([(1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)]);
// outside a counter-clockwise square is the right of the walk; (-1, -1) and
// (-1, -0.5) are nearest the corner (0, 0), (0.5, -1) the bottom edge
for (x, y) in [(-1.0, -1.0), (-1.0, -0.5), (0.5, -1.0), (2.0, 2.0)] {
let point = Vec3::new(x, y, 0.0);
for curve in [&seam_at_corner, &corner_inside] {
assert_eq!(
find_side_of_curve(&k, curve, point, Vec3::Z, 1e-6),
Ok(true),
"({x}, {y})"
);
}
}
}
#[test]
fn size_estimates_follow_the_context() {
let mut k = Kernel::default();
@@ -397,11 +397,10 @@
}
},
"shell": {
"cases": 37,
"cases": 36,
"chain": {
"11": 1,
"4": 30,
"5": 1,
"7": 2,
"8": 1
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

@@ -1,212 +0,0 @@
{
"area": "shell",
"bodies": {
"b2": {
"keeping_splines": false,
"trusted": true
}
},
"calls": [
{
"inputs": [],
"op": "revolve",
"outputs": [
"b1"
],
"params": {
"angle": 6.283185307179586,
"cap": true,
"direction": [
-1.0,
-0.0,
-0.0
],
"origin": [
0.0,
0.0,
0.0
],
"profile": [
{
"line": {
"end": [
0.0,
0.0,
0.0
],
"start": [
40.0,
0.0,
0.0
]
}
},
{
"line": {
"end": [
0.0,
0.0,
-15.0
],
"start": [
0.0,
0.0,
0.0
]
}
},
{
"line": {
"end": [
40.0,
0.0,
0.0
],
"start": [
0.0,
0.0,
-15.0
]
}
}
]
}
},
{
"inputs": [
"b2"
],
"op": "copy",
"outputs": [
"b3"
],
"params": {}
},
{
"inputs": [
"b3"
],
"op": "copy",
"outputs": [
"b4"
],
"params": {}
},
{
"inputs": [
"b4"
],
"op": "tagged",
"outputs": [
"b5"
],
"params": {
"operand": 0
}
},
{
"inputs": [
"b5"
],
"op": "shell",
"outputs": [
"b6"
],
"params": {
"open_faces": {
"kind": "face",
"picked": [
{
"at": [],
"index": 1
}
],
"points": [
[
0.0,
3.1411078843307365,
4.997542771220968
]
],
"tolerance": 0.05831051894845301
},
"thickness": -2.0
}
}
],
"case": "11774-shell-cone-apex-open-base",
"expected": {
"area_mm2": 3680.727952342112,
"bbox": {
"max": [
40.0,
15.0,
15.0
],
"min": [
0.0,
-15.0,
-15.0
]
},
"body_count": 1,
"center_of_mass": [
12.432420809751424,
0.0,
0.0
],
"faces": 3,
"method": "half-angle a with cos(a) = 40/sqrt(1825); the wall is the lateral face offset 2 normal to the cone, so the inner cone's radius at x = 0 is r' = 15 - 2/cos(a) = 12.863999063670617 and its apex is 2/sin(a) = 5.696002496903573 before the outer one, at h' = 40 r'/15 = 34.30399750312164. The base is open and the wall keeps 2 across the slant: V = pi/3 (15^2 40 - r'^2 h'). Area: the outer lateral pi 15 sqrt(1825), the inner lateral pi r' sqrt(h'^2 + r'^2), the base ring pi (15^2 - r'^2). Faces: outer cone, inner cone, base ring = 3. Centre of mass on the axis at x = (15^2 40^2/4 - r'^2 h'^2/4) / (15^2 40 - r'^2 h')",
"source": "analytic",
"sources": [
"tangency-labs/solidcraft#11774"
],
"volume_mm3": 3480.1408323900278,
"what": "a cone (r 15 at x = 0, apex at x = 40) shelled 2 with its base open: before tangency-labs/solidcraft#11774 was fixed the shell refused with status 1001, because the offset cone truncated at its new apex kept its parameter box at the box's other end, the rim's UV moved, and the executive could not pair the rim edge use with its offset copy"
},
"expressible": true,
"operation_expressible": true,
"outcome": {
"ran": true,
"raw_counts": {
"edges": 4,
"faces": 3,
"vertices": 4
}
},
"records": "11774-shell-cone-apex-open-base.bodies.json.gz",
"result": [
{
"body": "Cone",
"made": "b6"
}
],
"rig": {
"error": null,
"known_failing": null,
"measured": {
"area_mm2": 3680.727952345433,
"body_count": 1,
"edges": 6,
"faces": 3,
"vertices": 6,
"volume_mm3": 3480.1408323550913
},
"refused_with": null,
"status": "pass"
},
"sources": [
"tangency-labs/solidcraft#11774"
],
"traced_ops": [
"revolve",
"copy",
"copy",
"tagged",
"shell"
],
"ungraded": [],
"what": "a cone (r 15 at x = 0, apex at x = 40) shelled 2 with its base open: before tangency-labs/solidcraft#11774 was fixed the shell refused with status 1001, because the offset cone truncated at its new apex kept its parameter box at the box's other end, the rim's UV moved, and the executive could not pair the rim edge use with its offset copy",
"why_not": [],
"gallery_note": "the shell builds: the truncated offset cone's parameter box is anchored at the rim's end, so the rim pair matches and the mitre circle replaces the rim's raw offset curve"
}
+22 -319
View File
@@ -1,6 +1,5 @@
//! `query::tangent_edge` (#3141): whether an edge's faces meet tangentially all along it,
//! against closed forms: the angle between their outward normals is bracketed by the verdicts
//! just below it (not tangent, by a witness) and just above it (no witness).
//! `query::tangent_edge` (#3141): whether an edge's faces meet tangentially all along it, and
//! the least angle between their outward normals, against closed forms.
//!
//! - a box: every edge's faces meet square, pi/2;
//! - a wedge whose two sloped faces meet at an inside angle of 60 degrees along its ridge: the
@@ -23,23 +22,14 @@
use solidcore::blend::fillet_executive::api::fillet_edges;
use solidcore::curve::curve::Curve;
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::math::{Point2, Point3, Vec3};
use solidcore::nurbs::b_spline_curve::BSplineCurve;
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
use solidcore::nurbs::b_spline_surface::BSplineSurface;
use solidcore::nurbs::knots::Side;
use solidcore::nurbs::math::CPoint;
use solidcore::math::{Point3, Vec3};
use solidcore::primitive::api_curves::{create_arc, create_line_segment};
use solidcore::primitive::api_primitives::{create_box, create_cylinder};
use solidcore::query::api_queries::{edge_closest_point, get_edges};
use solidcore::query::edge::edge_faces;
use solidcore::query::edge_turn::{convex_at, turn_at};
use solidcore::query::tangent_edge::{tangent_all_along, Tangency};
use solidcore::surface::surface::{Surface, SurfaceKind};
use solidcore::query::tangent_edge::{least_angle_along, tangent_all_along, Tangency};
use solidcore::sweep::api_brep::create_linear_sweep;
use solidcore::topology::edgeuse::p1::{self as eu1, set_uv_trim_curve, UvTrimCurveArg};
use solidcore::topology::{edge, face, EdgeId, EdgeuseId, FaceId, VertexId};
use solidcore::topology::{edge, EdgeId, VertexId};
use solidcore::{Brep, Kernel};
use std::f64::consts::{FRAC_PI_2, PI};
@@ -61,13 +51,10 @@ fn pick(k: &Kernel, b: &Brep, q: Point3) -> EdgeId {
.0
}
/// Edge `e`'s faces part by `want` (radians) somewhere along it, to `tol`: a witness proves
/// them more than `want - tol` apart, and none proves them more than `want + tol` apart.
fn parts_by(k: &Kernel, b: &mut Brep, e: EdgeId, want: f64, tol: f64) {
let below = tangent_all_along(k, b, e, want - tol).unwrap();
assert_eq!(below, Some(Tangency::NotTangent), "{e:?} below {want}");
let above = tangent_all_along(k, b, e, want + tol).unwrap();
assert_ne!(above, Some(Tangency::NotTangent), "{e:?} above {want}");
fn angle(k: &Kernel, b: &mut Brep, e: EdgeId) -> f64 {
let a = least_angle_along(k, b, e).unwrap().expect("two faces");
assert_eq!(a.margin, 0.0, "no blend faces here");
a.angle
}
#[test]
@@ -77,7 +64,8 @@ fn every_edge_of_a_box_is_square() {
let edges = get_edges(&b).unwrap();
assert_eq!(edges.len(), 12);
for e in edges {
parts_by(&k, &mut b, e, FRAC_PI_2, 1e-12);
let a = angle(&k, &mut b, e);
assert!((a - FRAC_PI_2).abs() < 1e-12, "{e:?}: {a}");
assert_eq!(
tangent_all_along(&k, &mut b, e, SMOOTH).unwrap(),
Some(Tangency::NotTangent)
@@ -102,9 +90,11 @@ fn a_wedge_s_ridge_is_its_dihedral_s_supplement() {
let x = Vec3::new(1.0, 0.0, 0.0);
let mut b = create_linear_sweep(&mut k, &refs, x, 10.0, true).unwrap();
let ridge = pick(&k, &b, Point3::new(5.0, 0.0, h));
parts_by(&k, &mut b, ridge, 2.0 * PI / 3.0, 1e-12);
let a = angle(&k, &mut b, ridge);
assert!((a - 2.0 * PI / 3.0).abs() < 1e-12, "{a}");
let base = pick(&k, &b, Point3::new(5.0, half, 0.0));
parts_by(&k, &mut b, base, 2.0 * PI / 3.0, 1e-12);
let a = angle(&k, &mut b, base);
assert!((a - 2.0 * PI / 3.0).abs() < 1e-12, "{a}");
}
/// A cylinder r 3, 7 high: its side meets each cap square along the cap's circle, and meets
@@ -116,7 +106,8 @@ fn a_cylinder_s_side_meets_its_caps_square() {
let mut b = create_cylinder(&mut k, Point3::new(0.0, 0.0, 0.0), 3.0, 7.0).unwrap();
for at in [Point3::new(0.0, 3.0, 0.0), Point3::new(0.0, -3.0, 7.0)] {
let e = pick(&k, &b, at);
parts_by(&k, &mut b, e, FRAC_PI_2, 1e-9);
let a = angle(&k, &mut b, e);
assert!((a - FRAC_PI_2).abs() < 1e-9, "{at:?}: {a}");
assert_eq!(
tangent_all_along(&k, &mut b, e, SMOOTH).unwrap(),
Some(Tangency::NotTangent)
@@ -139,22 +130,16 @@ fn a_round_meets_its_faces_smoothly() {
fillet_edges(&mut k, &mut b, &[e], 1.0, 1, 2, 0.0).unwrap();
for at in [Point3::new(5.0, 1.0, 4.0), Point3::new(5.0, 0.0, 3.0)] {
let e = pick(&k, &b, at);
let a = angle(&k, &mut b, e);
assert!(a < 1e-9, "{at:?}: {a}");
assert_eq!(
tangent_all_along(&k, &mut b, e, SMOOTH).unwrap(),
Some(Tangency::Tangent)
);
// the round's faces are tangent at each end too: the side's enclosure holds 0, so
// the edge is neither convex nor concave there
for v in [
edge::start_vertex(&b, e).unwrap(),
edge::end_vertex(&b, e).unwrap(),
] {
let v = v.expect("an end");
assert_eq!(convex_at(&k, &mut b, e, v, SMOOTH).unwrap(), None, "{at:?}");
}
}
let back = pick(&k, &b, Point3::new(5.0, 6.0, 4.0));
parts_by(&k, &mut b, back, FRAC_PI_2, 1e-12);
let a = angle(&k, &mut b, back);
assert!((a - FRAC_PI_2).abs() < 1e-12, "{a}");
}
/// The vertex edges `e` and `f` share.
@@ -245,289 +230,7 @@ fn an_l_section_s_inside_corner_is_concave() {
edge::end_vertex(&b, e).unwrap(),
] {
let v = v.expect("an end");
assert_eq!(
convex_at(&k, &mut b, e, v, SMOOTH).unwrap(),
Some(convex),
"{at:?}"
);
assert_eq!(convex_at(&k, &mut b, e, v).unwrap(), Some(convex), "{at:?}");
}
}
}
/// A prism 5 high on (0, 0), (10, 0), (20, 10 tan a), (20, 12), (0, 12): its vertical edge at
/// (10, 0) is between two walls whose normals are `a` apart (0.3 degrees), planes, so the bound
/// is the angle itself (one point, `L` 1).
fn kinked(k: &mut Kernel, a: f64) -> Brep {
let p = |x: f64, y: f64| Point3::new(x, y, 0.0);
let pts = [
p(0.0, 0.0),
p(10.0, 0.0),
p(20.0, 10.0 * a.tan()),
p(20.0, 12.0),
p(0.0, 12.0),
];
let curves: Vec<Curve> = (0..5)
.map(|i| Curve::from(create_line_segment(pts[i], pts[(i + 1) % 5]).unwrap()))
.collect();
let refs: Vec<Option<&Curve>> = curves.iter().map(Some).collect();
create_linear_sweep(k, &refs, Vec3::new(0.0, 0.0, 1.0), 5.0, true).unwrap()
}
/// Walls 0.3 degrees apart: tangent at 0.5 (by the bound, not exact parallelism), not tangent
/// at 0.2 (by a witness), undecided at the angle itself (neither the bound nor the witness
/// clears it).
#[test]
fn walls_three_tenths_of_a_degree_apart() {
let mut k = Kernel::default();
let a = 0.3_f64.to_radians();
let mut b = kinked(&mut k, a);
let e = pick(&k, &b, Point3::new(10.0, 0.0, 2.5));
let at = |b: &mut Brep, deg: f64| tangent_all_along(&k, b, e, deg.to_radians()).unwrap();
assert_eq!(at(&mut b, 0.5), Some(Tangency::Tangent));
assert_eq!(at(&mut b, 0.2), Some(Tangency::NotTangent));
// undecided, and named where: a piece of the edge (the walls are planes, so the edge is
// one piece, its middle)
let Some(Tangency::Undecided { at: place, .. }) = at(&mut b, 0.3) else {
panic!("{:?}", at(&mut b, 0.3));
};
let t = edge::interval(&b, e).unwrap();
assert!(t.min() < place && place < t.max(), "{place} not on {t:?}");
}
/// A procedural round whose definition is enclosed (the ball's circle of one radius between
/// NURBS faces, `gn_def_face_of`): `wr698_gn_round_is_its_definition`'s `circle_root`, a
/// 60 x 40 x 10 plate with a pipe of radius 10 lying along it 4 degrees off x, sunk into
/// it, the root edge rounded r 2 by the ball's exact circle. The round's edges along the
/// root.
fn procedural_round(k: &mut Kernel) -> (Brep, Vec<EdgeId>) {
use solidcore::boolean::api_brep::boolean_union;
k.fixes.fillet_round_as_definition = true;
let plate = create_box(k, Point3::new(0.0, 0.0, 0.0), 60.0, 40.0, 10.0).unwrap();
let mut pipe = create_cylinder(k, Point3::new(0.0, 0.0, 0.0), 10.0, 60.0).unwrap();
let phi = 4.0_f64.to_radians();
let place = Axis2Placement::new(
Point3::new(0.0, 20.0, 16.0),
Vec3::new(-phi.sin(), 0.0, -phi.cos()),
Vec3::new(0.0, 1.0, 0.0),
);
solidcore::topology::brep::p5::transform(k, &mut pipe, &place, None, false).unwrap();
let mut b = boolean_union(k, plate, pipe).unwrap();
// the root edge nearest (30, 29.2, 10), as `circle_root` takes it
let q = Point3::new(30.0, 29.2, 10.0);
let root = get_edges(&b)
.unwrap()
.into_iter()
.filter_map(|e| {
edge_closest_point(k, &b, e, q)
.ok()
.map(|c| (e, c.closest.distance))
})
.filter(|&(_, d)| d <= 1.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.expect("the root edge")
.0;
fillet_edges(k, &mut b, &[root], 2.0, 1, 1, 1.0).unwrap();
let rounds: Vec<FaceId> = b
.faces()
.ids()
.filter(|&f| {
face::p2::surface(&b, f)
.unwrap()
.is_some_and(|s| s.procedural_blend().is_some())
})
.collect();
assert!(!rounds.is_empty(), "the round is a definition");
// the pipe lies along x (its axis 4 degrees off it), so the root runs along x and the
// round ends in cross-sections at the plate's sides: the edges there (spanning little in
// x) meet those faces square, not tangentially; the rest run along the root, into the
// plate's top and the pipe
let mut edges: Vec<EdgeId> = Vec::new();
for &f in &rounds {
for e in face::p4::edges(&b, f, None, false, None).unwrap() {
let t = edge::interval(&b, e).unwrap();
let curve = edge::curve(&b, e).unwrap().expect("an edge curve");
let x = |t: f64| curve.evaluate_point(k, t).unwrap().x;
if (x(t.max()) - x(t.min())).abs() > 1.0 && !edges.contains(&e) {
edges.push(e);
}
}
}
assert!(edges.len() >= 2, "{edges:?}");
(b, edges)
}
/// The procedural round's edges along the root, decided on its net with its definition's
/// margin: today's verdict, exactly, on each: Undecided with no bound (pi, pi) at `s` = 87.04,
/// where the pipe's foot crosses its net's C1 quarter knot (u = 15.708, multiplicity 2) and the
/// definition's partials over the feet's box are refused (`FEET_BOX_CONTINUITY`). w2949's
/// C1-knot follow-up encloses that box: then this test fails, and is to say Tangent (the
/// module's unit test `a_procedural_round_s_edges_carry_its_margin` pins the parts).
#[test]
fn a_procedural_round_is_smooth() {
let mut k = Kernel::default();
let (mut b, edges) = procedural_round(&mut k);
for e in edges {
let verdict = tangent_all_along(&k, &mut b, e, SMOOTH).unwrap();
let Some(Tangency::Undecided { angle, margin, at }) = verdict else {
panic!("{e:?}: {verdict:?} (the C1 knot enclosed? then this test is to say Tangent)");
};
let unbounded = angle.to_bits() == PI.to_bits() && margin.to_bits() == PI.to_bits();
assert!(unbounded, "{e:?}: {verdict:?}");
// 87.04 is read off a run: the middle of the first part noted undecided there, where
// the pipe's foot crosses its quarter knot at s = 87.22 (the foot's map s -> u is the
// definition's solve, not redone here)
assert!((at - 87.04).abs() < 0.01, "{e:?}: {verdict:?}");
}
}
/// The edge use of `e` on a face that is not a plane, and that face.
fn curved_use(b: &Brep, e: EdgeId) -> (EdgeuseId, FaceId) {
edge::edgeuses(b, e, None, None)
.unwrap()
.into_iter()
.find_map(|eu| {
let f = eu1::face(b, eu).unwrap()?;
let s = face::p2::surface(b, f).unwrap()?;
(!matches!(s.kind(), SurfaceKind::Plane(_))).then_some((eu, f))
})
.expect("an edge use on a curved face")
}
/// The pcurve of `eu` moved by `(du, dv)` in its face's parameters, put back on `eu`.
fn shift_pcurve(k: &Kernel, b: &mut Brep, eu: EdgeuseId, du: f64, dv: f64) -> Vec<Point3> {
let mut pc = eu1::uv_trim_curve(b, eu)
.unwrap()
.0
.expect("a pcurve")
.clone();
let by = Axis2Placement::new(Point3::new(du, dv, 0.0), Vec3::X, Vec3::Y);
pc.transform(k, &by, None).unwrap();
let points = pc
.b_spline_base()
.expect("a NURBS pcurve")
.control_polygon()
.unwrap()
.0;
set_uv_trim_curve(b, eu, UvTrimCurveArg::New(Box::new(pc)), 0.0, true, None).unwrap();
points
}
/// A cylinder's side face is periodic: its cap edge's pcurve stored a period off (shifted by
/// the face's whole domain along its periodic parameter) lies wholly past the net's domain.
/// The face's evaluator clamps there (it does not wrap), so the face's point is a whole
/// period's run from the pcurve's: not on its face, and the decision refuses it by name.
#[test]
fn a_pcurve_a_period_off_its_face_is_refused() {
let mut k = Kernel::default();
let mut b = create_cylinder(&mut k, Point3::new(0.0, 0.0, 0.0), 3.0, 7.0).unwrap();
let e = pick(&k, &b, Point3::new(0.0, 3.0, 0.0));
let (eu, f) = curved_use(&b, e);
let side = face::p2::surface(&b, f).unwrap().unwrap().clone();
let dom = side.natural_uv_domain_with_nurb(&k).unwrap();
let in_u = side.is_periodic(&k, &dom, SurfParamType::U).unwrap();
let in_v = side.is_periodic(&k, &dom, SurfParamType::V).unwrap();
assert!(in_u || in_v, "a cylinder's side is periodic");
let (du, dv) = if in_u {
(dom.x_length(), 0.0)
} else {
(0.0, dom.y_length())
};
assert_eq!(
tangent_all_along(&k, &mut b, e, SMOOTH).unwrap(),
Some(Tangency::NotTangent),
"before the shift"
);
shift_pcurve(&k, &mut b, eu, du, dv);
let err = tangent_all_along(&k, &mut b, e, SMOOTH).unwrap_err();
assert!(
format!("{err:?}").contains("leaving its face's net"),
"{err:?}"
);
}
/// The prism of `kinked` with `a` = 10 degrees: its walls A on y = 0 and B turned `a` from it
/// meet at the vertical edge through (10, 0). Wall A's surface is replaced by one degree-1
/// B-spline over both walls' planes, two spans in u with the knot 1/2 at that edge (u: (0, 0),
/// (10, 0), (20, 10 tan a); v: z from 0 to 5): A's region is its first span, as before. A's
/// pcurve on the edge is put on the knot line, `1e-15` past it and `1e-15` before it. Past it
/// the net is the second span's plane, B's, so the faces meet smoothly; before it, the first
/// span's, 10 degrees off; on it, the edge's box meets the line, so the piece is bounded over
/// both cells and is not proved smooth. Past the net's end the face's evaluator clamps: a
/// pcurve `1e-12` past it is taken, one 0.5 past it refused. The sweep cannot make such a
/// face (it splits a profile at a kink into faces), hence the surface put in by hand.
#[test]
fn a_pcurve_1e_15_off_a_knot_line_is_on_its_own_side() {
let mut k = Kernel::default();
let a = 10.0_f64.to_radians();
let verdict = |k: &mut Kernel, at: f64| {
let mut b = kinked(k, a);
let e = pick(k, &b, Point3::new(10.0, 0.0, 2.5));
// wall A: the face of the edge that also bounds wall A's bottom edge through (5, 0, 0)
let bottom = pick(k, &b, Point3::new(5.0, 0.0, 0.0));
let below = edge_faces(&b, bottom).unwrap();
let f = edge_faces(&b, e)
.unwrap()
.into_iter()
.find(|f| below.contains(f))
.expect("wall A");
let eu = edge::edgeuses(&b, e, None, None)
.unwrap()
.into_iter()
.find(|&eu| eu1::face(&b, eu).unwrap() == Some(f))
.expect("wall A's use");
// the net's normal Su x Sv keeps the plane's sense: x along u, so z along v where the
// plane's normal is -y, and down where it is +y
let plane = face::p2::surface(&b, f).unwrap().unwrap().clone();
let n = plane
.evaluate_normal(k, Point2::new(0.0, 0.0), Side::Left, Side::Left)
.unwrap();
assert!(
n.x.abs() < 1e-12 && n.z.abs() < 1e-12,
"wall A's normal {n:?}"
);
let (z0, z1) = if n.y < 0.0 { (0.0, 5.0) } else { (5.0, 0.0) };
let row = |x: f64, y: f64| vec![CPoint::new(x, y, z0, 1.0), CPoint::new(x, y, z1, 1.0)];
let net = [row(0.0, 0.0), row(10.0, 0.0), row(20.0, 10.0 * a.tan())];
let (u, v) = ([0.0, 0.0, 0.5, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
let s = BSplineSurface::from_data(2, 1, 1, 1, 4, 3, &net, &u, &v).unwrap();
b.face_mut(f).unwrap().surface = Some(Surface::from(s));
let t = edge::interval(&b, e).unwrap();
let cpw = [
CPoint::new(at, 0.0, 0.0, 1.0),
CPoint::new(at, 1.0, 0.0, 1.0),
];
let knots = [t.min(), t.min(), t.max(), t.max()];
let pc = Curve::from(BSplineCurve::from_data(3, 1, 3, 1, &cpw, &knots).unwrap());
set_uv_trim_curve(
&mut b,
eu,
UvTrimCurveArg::New(Box::new(pc)),
0.0,
true,
None,
)
.unwrap();
tangent_all_along(k, &mut b, e, SMOOTH)
};
assert_eq!(
verdict(&mut k, 0.5 + 1e-15).unwrap(),
Some(Tangency::Tangent)
);
assert_eq!(
verdict(&mut k, 0.5 - 1e-15).unwrap(),
Some(Tangency::NotTangent)
);
assert_ne!(verdict(&mut k, 0.5).unwrap(), Some(Tangency::Tangent));
// past the net's domain [0, 1] the face's evaluator clamps: 1e-12 past its end the
// clamped point is within the edge's tolerance of the net's, so the pcurve is taken (the
// second span's plane, smooth); 0.5 past it, it moves 10 or more: refused, named
assert_eq!(
verdict(&mut k, 1.0 + 1e-12).unwrap(),
Some(Tangency::Tangent)
);
let err = verdict(&mut k, 1.5).unwrap_err();
assert!(
format!("{err:?}").contains("leaving its face's net"),
"{err:?}"
);
}
@@ -217,7 +217,6 @@ fn round_flux(ball: &dyn Fn(f64) -> Ball, o: Point3) -> (f64, f64) {
/// radial frame `e1`, `e2`) between the edge and the rail, `n` into the cylinder: the rail
/// at the spine's height `ws` along the axis, the edge at `we`; trapezoid over the loop.
fn cylinder_flux(
rr: f64,
e1: Vec3,
e2: Vec3,
o: Point3,
@@ -229,7 +228,7 @@ fn cylinder_flux(
for i in 0..nt {
let t = 2.0 * PI * f64::from(i) / f64::from(nt);
let u = radial(e1, e2, t);
total += -(rr - o.dot(u)) * rr * (ws(t) - we(t));
total += -(R - o.dot(u)) * R * (ws(t) - we(t));
}
total * (2.0 * PI / f64::from(nt)) / 3.0
}
@@ -264,11 +263,6 @@ fn plate_and_peg_kept(exact: bool, definition: bool) -> (Kernel, Brep, f64, Vec<
/// origin: the plate's strip lies in z = 0 and contributes nothing, the cylinder's
/// `-(R / 3) R 2 pi r / cos(tau)`.
fn plate_and_peg_round(tau: f64) -> (impl Fn(f64) -> Point3, f64, f64) {
peg_round(R, tau)
}
/// [`plate_and_peg_round`] for a peg of radius `rr`.
fn peg_round(rr: f64, tau: f64) -> (impl Fn(f64) -> Point3, f64, f64) {
let (e1, e2, a) = (
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, tau.cos(), tau.sin()),
@@ -277,17 +271,15 @@ fn peg_round(rr: f64, tau: f64) -> (impl Fn(f64) -> Point3, f64, f64) {
// on the cylinder of radius rho about the peg's axis at angle t, the height along the
// axis where the plane z = h is
let w = move |rho: f64, h: f64, t: f64| (h - rho * radial(e1, e2, t).z) / a.z;
let spine = move |t: f64| radial(e1, e2, t) * (rr + FILLET) + a * w(rr + FILLET, FILLET, t);
let spine = move |t: f64| radial(e1, e2, t) * (R + FILLET) + a * w(R + FILLET, FILLET, t);
let ball = |t: f64| Ball {
c: spine(t),
a: Vec3::new(0.0, 0.0, -1.0),
b: -radial(e1, e2, t),
};
let o = Point3::new(0.0, 0.0, 0.0);
let strip = cylinder_flux(rr, e1, e2, o, &|t| w(rr + FILLET, FILLET, t), &|t| {
w(rr, 0.0, t)
});
assert!((strip + rr * rr * 2.0 * PI * FILLET / (3.0 * tau.cos())).abs() < 1e-12);
let strip = cylinder_flux(e1, e2, o, &|t| w(R + FILLET, FILLET, t), &|t| w(R, 0.0, t));
assert!((strip + R * R * 2.0 * PI * FILLET / (3.0 * tau.cos())).abs() < 1e-12);
let (flux, area) = round_flux(&ball, o);
(spine, flux + strip, area)
}
@@ -414,7 +406,7 @@ fn pole_and_cone_round() -> (impl Fn(f64) -> Point3, f64, f64) {
};
// about the apex the cone's strip contributes nothing
let (flux, area) = round_flux(&ball, apex);
let added = flux + cylinder_flux(R, e1, e2, apex, &|t| w(rho, t), &|t| w(R, t));
let added = flux + cylinder_flux(e1, e2, apex, &|t| w(rho, t), &|t| w(R, t));
(spine, added, area)
}
@@ -568,43 +560,3 @@ fn the_conic_pair_rounds_are_their_definitions() {
let (k0, b0, _, _) = pole_and_cone_kept(true, false);
assert_eq!(body_bits(&k, &b), body_bits(&k0, &b0));
}
/// tangency-labs/solidcraft#14000, #15037: a boss r5 standing 10 out of a 40 x 40 x 10 block
/// (sunk 5 into it), its axis turned a few degrees off square, its root rounded r1. The
/// supports are a plane and a cylinder not square to it: a conic round on an ellipse
/// spine that is proved on both offsets once the plane's section is computed without
/// cancellation. At every tilt the round keeps its exact definition (bound 0, re-certified,
/// through its record) and adds the divergence theorem's volume.
#[test]
fn a_round_on_a_boss_tilted_a_few_degrees_is_its_definition() {
let boss = 5.0;
for deg in [0.25_f64, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0] {
let tau = deg.to_radians();
let mut k = Kernel::default();
k.fixes.fillet_exact_round_classes = true;
k.fixes.fillet_round_as_definition = true;
let block = create_box(&mut k, Point3::new(-20.0, -20.0, -10.0), 40.0, 40.0, 10.0).unwrap();
let mut peg = create_cylinder(&mut k, Point3::new(0.0, 0.0, -5.0), boss, 15.0).unwrap();
tilt(&mut k, &mut peg, tau);
let mut b = boolean_union(&mut k, block, peg).unwrap();
let before = volume(&k, &mut b);
let root = edges_where(&k, &b, |m| {
m.z.abs() < 1e-6 && m.x.abs() < 10.0 && m.y.abs() < 10.0
});
assert!(!root.is_empty(), "{deg} degrees: the root");
fillet_edges(&mut k, &mut b, &root, FILLET, 1, 1, 1.0)
.unwrap_or_else(|e| panic!("{deg} degrees: {e:?}"));
let checked = exact_round_checks(&k, &b);
assert_eq!(checked.len(), root.len(), "{deg} degrees: {checked:?}");
assert!(
checked.iter().all(|(c, _)| *c == ExactClass::Conic),
"{deg} degrees: {checked:?}"
);
let (_, added, _) = peg_round(boss, tau);
let after = volume(&k, &mut b);
assert!(
(after - before - added).abs() < 1e-3,
"{deg} degrees: {before} + {added} != {after}"
);
}
}
-1
View File
@@ -276,7 +276,6 @@ written as the operations it counts:
| `blend::fillet_section::recertify::precision_floor` | G (D + γ_K M), K = 2(n + 1) + 6 | the net's rows from the stations |
| `surface::procedural_sweep::ProceduralSweep::floor` | G γ_(2(p+3)+24) (A + (R + λ)Θ + R) | the sweep's net and evaluation |
| `surface::implicit::lies_on` | γ_K, K = 4d(p + 1)(q + 1) + 16 | an implicit equation's Bernstein coefficient |
| `query::tangent_edge` (`node_error`, `lebesgue`, `piece_bound`) | δ = (γ_4 π + 4u + γ_3(\|m\|/h + 1)) (1 + γ_4): the angle, its cosine (libm, 2 ulps) and the mapped node; Λ ×(1 + 16u); the atan/acos results ×(1 + 8u) | the Chebyshev nodes' rounding (the Markov term ΛδD²) and the tangency angle bound |
| `sweep::stations` (`rail_crossing`) | γ_5 S_i, S_i = Σ_j (\|x_ij\| + \|p_j\| w_i) \|t_j\| | a rail's Bernstein distance coefficient from a station's plane; within it, taken as 0 |
| `surface::extend::analytic` (private `certify_iso`) | γ_(12L+6) A_j, plus the plane's own (a parallel's height γ_(12L+7), a meridian's normal γ_(12L+8) + γ_12), × (1 + γ_16) | an extended analytic's iso control point off its plane; L de Boor levels, A_j its stencil's size (#15039) |
| `surface::sphere::meridian_rounding` | S · 1.75 (γ_K + γ_33) \|P\| + 3.5 γ_4 \|P\| + γ_31 π r, K = 16(p + q + 2) | a half meridian's end angle as `IsArc` fits it; S its first-order conditioning, read off the fit (`end_angle_conditioning`) (#15039) |
@@ -1,64 +0,0 @@
# The apex-truncated cone offset anchored its UV domain at the wrong end
Unit: `surface.cone_p2` (`Cone::create_offset_surface`, the one-radius-below-tolerance
branch). Found chasing tangency-labs/solidcraft#11774 (a shell on a cone with an apex
refused with `status 1001`); the inside-out reversal was found in review of that fix
(CodeRabbit on tangency-labs/solidcraft#14981).
## The defect
The offset of a cone whose radius passes zero is the cone truncated at its new apex,
made by `create_canonical` and reparameterised "to the matching part of this NURB
domain". The matching part kept the domain's minimum and scaled the maximum
(`min + new_height / height * max`), whatever end the radius collapsed at. When the
radius that passes zero is at the domain's minimum (the apex end of a revolved cone,
whose `bot_radius` is 0), the kept cone sits at the domain's *maximum* end: the
matching part is `[max - ratio * span, max]`, not `[min, min + ratio * span]`. The
offset of S is S + d n **at the same parameters** (offset-and-shell.md §1). With the
domain placed at the wrong end, corresponding points did not keep their parameters:
the rim's UV moved (40 to 35.006 in the #11774 cone), and every consumer that compares
UV points broke. In the shell that showed it: `map_edgeuses` (`offset_executive/p1.rs`)
pairs an original edge use with its offset copy by their UV midpoints and by dropping
the offset's UV point onto the original's UV trim curve; the rim pair matched neither,
so the rim edge use got no offset counterpart, `mate_edges` returned
`SM_ERR_NULL_POINTER` on its "can't be Null" guard (softly: the edge stayed unmated),
the rim kept its raw normal-offset circle instead of the mitre circle, and
`close_loop_gaps` died bridging the 0.75 mm step (`MakeVertexSplitEdge` refuses a split
at a curve's end). Every stage behaved to its own contract; the parameterisation
placement broke the one rule. The mirror branch (the radius at the domain's maximum
passing zero) was already anchored at the minimum, which is why truncated cones and
cylinders without an apex shelled correctly and only the apex case failed.
An inside-out cone's NURB runs the generator backwards
(`cone_convert_uv_from_nurbs_to_step` maps `step_ivl.min()` from `nurb_ivl.max()`), so
its surviving end sits at the domain's *other* end: the anchor is
`collapsed_at_base != inside_out`, not `collapsed_at_base`.
## The fix
`Cone::create_offset_surface` anchors the truncated cone's parameter box at the end
that survives (with the inside-out reversal). Geometry is unchanged; only the (u, v)
labels move. `Cone::p2` `offset_truncation_keeps_the_surviving_ends_parameters` derives
its expectations from the input's radius law and pins both collapse ends and both
inside-out values against the S + d n contract.
## What a later agent must know
- The span is scaled by `new_height / height`, correct only while the cone's height
dimension of the natural UV domain has the height's length (v runs bot to top over
`[0, height]`, or the reverse inside out). A cone whose domain is not the natural one
would need the ratio's span taken from the collapsed radius instead.
- **The kept height's slope is still upstream's** (`new_top_radius * height /
old_top_radius`): it assumes the other radius is zero and flattens a truncated input
cone's offset. Fixing it is filed separately (tangency-labs/solidcraft#15165) with the
closed-form analysis and the four `offset.offset_executive_p2` goldens it moves,
including one case whose corrected geometry turns a success into a refusal. Do not
fold it into an unrelated change; it needs its own look at that case.
- The record rebuild and the copy paths (`analytic_copy_keeps_parameterization`,
`copy_and_add_analytics`) preserve the domain, so a rebuilt cone shows the same
behaviour as an in-kernel one; the #11774 body reached the shell through a record
copy and reproduced without one.
- Upstream scales the domain maximum rather than its span (the branch's doc note): a
separate defect, unfixed here (min is 0 in every recorded case, where the two agree).
- The truncation branch is unreachable for a cylinder (both radii move together, so
both cross the tolerance at once).
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="900" height="400" viewBox="0 0 900 400">
<title>WR-775--projected-extremum-at-an-interval-end</title>
<g id="Layer_1" data-name="Layer 1">
<rect id="background" x="0" y="0" width="900" height="400" fill="#ffffff"/>
<text transform="matrix(1 0 0 1 20 34)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="17" font-weight="bold" fill="#000000"><tspan x="0" y="0">Projected nearest point of a curve interval, seen along the projection normal</tspan></text>
<text transform="matrix(1 0 0 1 40 72)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="14" font-weight="bold" fill="#000000"><tspan x="0" y="0">Foot inside the interval</tspan></text>
<path d="M40 300 C56.2 273 73.372 250.86 91.224 233.493" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<path d="M243.029 194.708 C279.688 208.806 316 239.1 350 285" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<path d="M91.224 233.493 C138.831 187.179 191.276 174.804 243.029 194.708" fill="none" stroke="#1f6fb2" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="142.489" y1="97.116" x2="165.572" y2="189.269" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<polyline points="174.302,187.082 172.115,178.352 163.385,180.538" fill="none" stroke="#555555" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="165.572" cy="189.269" r="4.5" fill="#000000"/>
<text transform="matrix(1 0 0 1 169.572 215.269)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="13" fill="#000000" text-anchor="middle"><tspan x="0" y="0">answer: the foot</tspan></text>
<text transform="matrix(1 0 0 1 255.029 198.708)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#555555"><tspan x="0" y="0">interval end</tspan></text>
<text transform="matrix(1 0 0 1 40 352)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#000000"><tspan x="0" y="0">The line from the foot to P is square to the curve.</tspan></text>
<text transform="matrix(1 0 0 1 40 370)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#000000"><tspan x="0" y="0">The search for feet finds it.</tspan></text>
<circle cx="91.224" cy="233.493" r="3.5" fill="#555555"/>
<circle cx="243.029" cy="194.708" r="3.5" fill="#555555"/>
<text transform="matrix(1 0 0 1 105.224 245.493)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#555555"><tspan x="0" y="0">interval start</tspan></text>
<circle cx="142.489" cy="97.116" r="4.5" fill="#000000"/>
<text transform="matrix(1 0 0 1 152.489 91.116)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="15" font-weight="bold" fill="#000000"><tspan x="0" y="0">P</tspan></text>
<text transform="matrix(1 0 0 1 490 72)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="14" font-weight="bold" fill="#000000"><tspan x="0" y="0">Foot outside the interval</tspan></text>
<path d="M490 300 C506.2 273 523.372 250.86 541.224 233.493" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<path d="M693.029 194.708 C729.688 208.806 766 239.1 800 285" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<path d="M541.224 233.493 C588.831 187.179 641.276 174.804 693.029 194.708" fill="none" stroke="#1f6fb2" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="837.993" y1="182.786" x2="769.45" y2="248.565" fill="none" stroke="#999999" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="5 4"/>
<polyline points="775.682,255.059 782.175,248.827 775.944,242.333" fill="none" stroke="#555555" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="837.993" y1="182.786" x2="693.029" y2="194.708" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<g>
<g>
<path d="M693.298 194.008 L749.206 215.509 C749.593 215.658 749.786 216.092 749.637 216.478 C749.488 216.865 749.055 217.058 748.668 216.909 L692.76 195.408 C692.373 195.259 692.18 194.825 692.329 194.438 C692.478 194.052 692.912 193.859 693.298 194.008" fill="#555555"/>
<path d="M750.897 216.963 L744.22 217.609 L746.977 215.455 L746.374 212.009 Z" fill="#555555"/>
</g>
</g>
<circle cx="769.45" cy="248.565" r="4.5" fill="#ffffff" stroke="#555555" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<text transform="matrix(1 0 0 1 755.45 268.565)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#555555" text-anchor="end"><tspan x="0" y="0">foot, outside the interval</tspan></text>
<text transform="matrix(1 0 0 1 727.029 230.708)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#555555" text-anchor="end"><tspan x="0" y="0">outward tangent</tspan></text>
<text transform="matrix(1 0 0 1 687.029 168.708)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="13" fill="#000000" text-anchor="end"><tspan x="0" y="0">answer: the interval end</tspan></text>
<text transform="matrix(1 0 0 1 490 352)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#000000"><tspan x="0" y="0">The line from the end to P leans outward: no point of the interval is nearer.</tspan></text>
<text transform="matrix(1 0 0 1 490 370)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#000000"><tspan x="0" y="0">The end is the answer. The search for feet alone returns nothing.</tspan></text>
<circle cx="541.224" cy="233.493" r="3.5" fill="#555555"/>
<circle cx="693.029" cy="194.708" r="4.5" fill="#000000"/>
<text transform="matrix(1 0 0 1 555.224 245.493)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="12" fill="#555555"><tspan x="0" y="0">interval start</tspan></text>
<circle cx="837.993" cy="182.786" r="4.5" fill="#000000"/>
<text transform="matrix(1 0 0 1 847.993 176.786)" x="0" y="0" xml:space="preserve" font-family="Helvetica, sans-serif" font-size="15" font-weight="bold" fill="#000000"><tspan x="0" y="0">P</tspan></text>
<line x1="440" y1="60" x2="440" y2="380" fill="none" stroke="#999999" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

File diff suppressed because it is too large Load Diff
@@ -1,12 +0,0 @@
#### WR-775. `SmCPGlobalSolver::LocalSolve` never returns a projected minimum or maximum that lies at an end of the interval
- **Severity:** Wrong result
- **Area:** Geometry queries
- **Location:** `source/SMLib/src/SmCurve.cpp:11290` and `:11351`, `SmCPGlobalSolver::LocalSolve` (def. `:10951`).
- **What happens:** For `SM_SO_PROJECTED_MINIMIZE` and `SM_SO_PROJECTED_MAXIMIZE` each guess is solved by `LocalPropertyAnalysis`, which finds parameters where the projected distance is stationary. The branch at `:11290` means to save a span's start or end point when a later guess finds no root, but it leaves `bFoundSolution` false. The test at `:11351` returns from the span as soon as the first guess finds no root, and the later `if (!bFoundSolution) continue` skips the save for every other guess. An end of the interval where the distance is still falling (rising) is no stationary point, so it is never returned. A point whose nearest (farthest) projected point is that end gets an empty success, or, when the curve has a stationary point elsewhere, that point is returned although the end is nearer (farther).
- **Trigger:** a projected minimum of the point (2, 0.5, 0) along Z onto the segment from (-1, 0, 0) to (1, 0, 0): the nearest point is the end (1, 0, 0) at distance sqrt(1.25), and the solve returns no solution. Over the goldens' 7109 projected point/curve solves on main `a5a698c96`, 335 solves without a target distance return nothing and 22 return a point that dense sampling (4001 samples) beats (tangency-labs/solidcraft#13658, #13677).
- **How found:** tracing tangency-labs/solidcraft#13658 (vertices of a shell against a bounded line: 5 of 12 vertices had no answer).
- **Confirmed:** by reading the C++ (the two tests above) and on the port, which followed it. Solidcore now adds the end of the solver's interval as a solution when the projected distance grows (shrinks) from it into the interval (`curve::curve::p1`, tests `projected_extremum_at_an_end_of_the_interval_is_a_solution` and `projected_maximum_of_a_straight_spline_is_at_its_ends`). `sm_FindSideOfCurve` keeps its failure for a point beyond the end of the curve.
![Two panels seen along the projection normal. Left: a point P above a curve interval; its foot lies inside the interval, where the line to P is square to the curve, and the foot is the answer. Right: P lies past the interval end; its foot is on the curve outside the interval, the line from the interval end to P leans along the outward tangent, and the interval end is the answer.](WR-775--projected-extremum-at-an-interval-end.svg)
The right panel is the missed case: the interval end is the nearest point, and it is not a foot. [Editable VectorCraft source](WR-775--projected-extremum-at-an-interval-end.vectorcraft), drawn with VectorCraft 0.24.0.
-2
View File
@@ -74,8 +74,6 @@ pub const TABLE: &[(&str, Class)] = &[
("ui-text", Class::Shared),
// the MCP server machinery: JSON-RPC over stdio, lifecycle, tool schemas and argument checks; apps register their tools (#12947)
("mcp-server", Class::Shared),
// versioned file formats: the format tag, the version registry and its upgrade, a newer file told; apps register their versions
("format", Class::Shared),
// the token contract UI components draw with: light and dark tokens, colour vision, tab accents, theme state (#13304)
("theme", Class::Shared),
// the UI kit and its components (ribbon, app bar, history and navigation bars, view compass, window chrome, theme files), drawn from host data (#14251)