mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44525da669 | |||
| 33c442f031 | |||
| 8d47023049 | |||
| 3c619790ce |
File diff suppressed because it is too large
Load Diff
@@ -1,622 +0,0 @@
|
||||
{
|
||||
"fi": {
|
||||
" Keys": " Avainruudut",
|
||||
" Maximum": " Enimmäisarvo",
|
||||
" Minimum": " Vähimmäisarvo",
|
||||
" Of": " Vapausaste",
|
||||
" Value": " Arvo",
|
||||
"ABS - Black": "ABS - musta",
|
||||
"ABS - Grey": "ABS - harmaa",
|
||||
"ABS - White": "ABS - valkoinen",
|
||||
"ALIGN": "KOHDISTA",
|
||||
"AS-BUILT JOINT": "NYKYASENNON MUKAINEN NIVEL",
|
||||
"ASSIGN PLASTIC RULE": "MÄÄRITÄ MUOVISÄÄNTÖ",
|
||||
"AXIS PERPENDICULAR TO FACE": "AKSELI KOHTISUORAAN PINTAAN",
|
||||
"AXIS THROUGH CYLINDER": "AKSELI SYLINTERIN KAUTTA",
|
||||
"AXIS THROUGH EDGE": "AKSELI SÄRMÄN KAUTTA",
|
||||
"AXIS THROUGH TWO PLANES": "AKSELI KAHDEN TASON KAUTTA",
|
||||
"AXIS THROUGH TWO POINTS": "AKSELI KAHDEN PISTEEN KAUTTA",
|
||||
"Add shortcut": "Lisää pikanäppäin",
|
||||
"Allowed values": "Sallitut arvot",
|
||||
"Aluminum - Satin": "Alumiini - satiini",
|
||||
"Anodized Black": "Anodisoitu musta",
|
||||
"Any": "Mikä tahansa",
|
||||
"BOSS": "TAPPI",
|
||||
"BOUNDARY FILL": "RAJATÄYTTÖ",
|
||||
"Brass": "Messinki",
|
||||
"Built-in": "Sisäänrakennettu",
|
||||
"By definition": "Määrittelyn mukaan",
|
||||
"By part number": "Osanumeron mukaan",
|
||||
"CAM": "CAM",
|
||||
"CONTACT SET": "KOSKETUSJOUKKO",
|
||||
"CONVERT MESH": "MUUNNA VERKKO",
|
||||
"CONVERT TO SHEET METAL": "MUUNNA OHUTLEVYKSI",
|
||||
"Chord length": "Jänteen pituus",
|
||||
"Chrome": "Kromi",
|
||||
"Close All Tabs": "Sulje kaikki välilehdet",
|
||||
"Close Other Tabs": "Sulje muut välilehdet",
|
||||
"Connect AI assistants…": "Yhdistä tekoälyavustajat…",
|
||||
"Copper": "Kupari",
|
||||
"Crash Reports…": "Kaatumisraportit…",
|
||||
"Curvature continuous (G2)": "Kaarevuusjatkuva (G2)",
|
||||
"DELETE FACE": "POISTA PINTA",
|
||||
"DELETE FEATURE": "POISTA PIIRRE",
|
||||
"DRIVE JOINTS": "OHJAA NIVELIÄ",
|
||||
"Default material": "Oletusmateriaali",
|
||||
"Design Properties…": "Mallin ominaisuudet…",
|
||||
"Direct interaction": "Suora vuorovaikutus",
|
||||
"Distance X": "Etäisyys X",
|
||||
"Distance Y": "Etäisyys Y",
|
||||
"Distance and Angle": "Etäisyys ja kulma",
|
||||
"Download design": "Lataa malli",
|
||||
"Drive parameter": "Ohjaa parametria",
|
||||
"Drive suppression": "Ohjaa ohitusta",
|
||||
"EDIT DIMENSION": "MUOKKAA MITTAA",
|
||||
"EDIT JOINT": "MUOKKAA NIVELTÄ",
|
||||
"EDIT MOTION STUDY": "MUOKKAA LIIKETUTKIMUSTA",
|
||||
"EDIT TEXT": "MUOKKAA TEKSTIÄ",
|
||||
"EMBOSS": "KOHOKUVIO",
|
||||
"EXPLODED VIEW": "RÄJÄYTYSKUVA",
|
||||
"EXTEND": "JATKA",
|
||||
"Edit Holes": "Muokkaa reikiä",
|
||||
"Exact": "Tarkka",
|
||||
"Export 3MF…": "Vie 3MF…",
|
||||
"Export Design as JSON…": "Vie malli JSON-muodossa…",
|
||||
"Export OBJ…": "Vie OBJ…",
|
||||
"Export STEP…": "Vie STEP…",
|
||||
"Export STL…": "Vie STL…",
|
||||
"Export URDF…": "Vie URDF…",
|
||||
"FLANGE": "LAIPPA",
|
||||
"FLAT PATTERN": "LEVITYSKUVA",
|
||||
"FOLD": "TAITA",
|
||||
"FRAME MEMBER": "RUNKOPALKKI",
|
||||
"Faces on Block": "Lohkon pinnat",
|
||||
"Faces on Ring": "Renkaan pinnat",
|
||||
"Flipped": "Käännetty",
|
||||
"Fold Features": "Kutista piirteet",
|
||||
"Follow inherited appearance": "Käytä perittyä ulkoasua",
|
||||
"Follow material": "Käytä materiaalin ulkoasua",
|
||||
"From Object": "Kohteesta",
|
||||
"GEAR": "HAMMASPYÖRÄ",
|
||||
"GROOVE": "URA",
|
||||
"Glass - Clear": "Lasi - kirkas",
|
||||
"Glass - Smoked": "Lasi - savustettu",
|
||||
"Groove": "Ura",
|
||||
"Group Features": "Ryhmitä piirteet",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "HDR-tiedosto on muuttunut valinnan jälkeen; tarkastele ja valitse se uudelleen ennen tallennusta",
|
||||
"Hold click": "Pidä painettuna",
|
||||
"INSERT PART": "LISÄÄ OSA",
|
||||
"INSERT SVG": "LISÄÄ SVG",
|
||||
"Insert Mesh…": "Lisää verkko…",
|
||||
"Insert STEP…": "Lisää STEP…",
|
||||
"JOINT": "NIVEL",
|
||||
"JOINT ORIGIN": "NIVELEN ORIGO",
|
||||
"MANAGE PLASTIC RULES": "HALLITSE MUOVISÄÄNTÖJÄ",
|
||||
"MIDPLANE": "KESKITASO",
|
||||
"MOTION LINK": "LIIKELINKKI",
|
||||
"MOTION STUDY": "LIIKETUTKIMUS",
|
||||
"MOTION TRACE": "LIIKERATA",
|
||||
"MOVE/COPY TEXT": "SIIRRÄ/KOPIOI TEKSTI",
|
||||
"Mesh": "Verkko",
|
||||
"NEW COMPONENT": "UUSI KOMPONENTTI",
|
||||
"Not": "Ei",
|
||||
"Nylon - Natural": "Nailon - luonnonvärinen",
|
||||
"OFFSET FACE": "PINNAN SIIRTYMÄ",
|
||||
"OFFSET SURFACE": "SIIRTYMÄPINTA",
|
||||
"Oak": "Tammi",
|
||||
"Offset X": "Siirtymä X",
|
||||
"Offset Y": "Siirtymä Y",
|
||||
"Offset Z": "Siirtymä Z",
|
||||
"PATCH": "PAIKKAPINTA",
|
||||
"PERPENDICULAR PLANE": "KOHTISUORA TASO",
|
||||
"PHYSICAL MATERIAL": "FYSIKAALINEN MATERIAALI",
|
||||
"PICK CONTEXT": "VALITSE KONTEKSTI",
|
||||
"PLANE ALONG PATH": "TASO POLKUA PITKIN",
|
||||
"PLANE THROUGH THREE POINTS": "TASO KOLMEN PISTEEN KAUTTA",
|
||||
"PLANE THROUGH TWO EDGES": "TASO KAHDEN SÄRMÄN KAUTTA",
|
||||
"POINT ALONG PATH": "PISTE POLKUA PITKIN",
|
||||
"POINT AT CENTER": "PISTE KESKIPISTEESEEN",
|
||||
"POINT AT EDGE AND PLANE": "PISTE SÄRMÄN JA TASON LEIKKAUKSEEN",
|
||||
"POINT AT VERTEX": "PISTE KÄRKIPISTEESEEN",
|
||||
"POINT THROUGH THREE PLANES": "PISTE KOLMEN TASON KAUTTA",
|
||||
"POINT THROUGH TWO EDGES": "PISTE KAHDEN SÄRMÄN KAUTTA",
|
||||
"Paint - Blue": "Maali - sininen",
|
||||
"Paint - Green": "Maali - vihreä",
|
||||
"Paint - Orange": "Maali - oranssi",
|
||||
"Paint - Purple": "Maali - violetti",
|
||||
"Paint - Red": "Maali - punainen",
|
||||
"Paint - Yellow": "Maali - keltainen",
|
||||
"Parts only": "Vain osat",
|
||||
"Play": "Toista",
|
||||
"Point to Point": "Pisteestä pisteeseen",
|
||||
"Point to Position": "Pisteestä sijaintiin",
|
||||
"Polycarbonate - Clear": "Polykarbonaatti - kirkas",
|
||||
"Preferences…": "Asetukset…",
|
||||
"RACK AND PINION": "HAMMASTANKO JA -PYÖRÄ",
|
||||
"REFOLD": "TAITA UUDELLEEN",
|
||||
"REMOVE": "POISTA",
|
||||
"RENAME": "NIMEÄ UUDELLEEN",
|
||||
"REPLACE FACE": "KORVAA PINTA",
|
||||
"REST": "TUKI",
|
||||
"REVERSE NORMAL": "KÄÄNNÄ NORMAALI",
|
||||
"RIGID GROUP": "JÄYKKÄ RYHMÄ",
|
||||
"Range": "Alue",
|
||||
"Reduce to Fit": "Pienennä sopivaksi",
|
||||
"Report an Issue…": "Ilmoita ongelmasta…",
|
||||
"Rolling": "Vierivä",
|
||||
"Rotate": "Kierrä",
|
||||
"Rubber - Black": "Kumi - musta",
|
||||
"Rules": "Säännöt",
|
||||
"SCREW": "RUUVI",
|
||||
"SHEET METAL RULES": "OHUTLEVYSÄÄNNÖT",
|
||||
"SNAP FIT": "NAPSAUTUSLIITOS",
|
||||
"SPLIT FACE": "JAA PINTA",
|
||||
"STITCH": "OMPELE",
|
||||
"STOCK": "AIHIO",
|
||||
"SURFACE EXTRUDE": "PINTAPURSOTUS",
|
||||
"SURFACE LOFT": "PINTALOFTTAUS",
|
||||
"SURFACE REVOLVE": "PINTAPYÖRÄYTYS",
|
||||
"SURFACE SWEEP": "PINTAPYYHKÄISY",
|
||||
"Save As JSON…": "Tallenna JSON-muodossa…",
|
||||
"Save As…": "Tallenna nimellä…",
|
||||
"Screenshot captured locally. Review it before attaching it on GitHub.": "Kuvakaappaus otettiin paikallisesti. Tarkista se ennen kuin liität sen GitHubissa.",
|
||||
"Scroll left": "Vieritä vasemmalle",
|
||||
"Scroll right": "Vieritä oikealle",
|
||||
"Select Features": "Valitse piirteet",
|
||||
"Select the curves to divide first": "Valitse ensin jaettavat käyrät",
|
||||
"Sheet Metal": "Ohutlevy",
|
||||
"Show Each Feature": "Näytä jokainen piirre",
|
||||
"Stainless Steel": "Ruostumaton teräs",
|
||||
"Steel": "Teräs",
|
||||
"Steel - Brushed": "Teräs - harjattu",
|
||||
"Suppress Features": "Ohita piirteet",
|
||||
"Suppression": "Ohitus",
|
||||
"Surface or face": "Pintakappale tai pinta",
|
||||
"Surface or faces": "Pintakappale tai pinnat",
|
||||
"TANGENT PLANE": "TANGENTTITASO",
|
||||
"THICKEN": "PAKSUNNA",
|
||||
"TRANSFORM": "MUUNNA",
|
||||
"Taper to Fit": "Kavenna sopivaksi",
|
||||
"Target edges": "Kohdesärmät",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Origon akseli on kohtisuorassa luonnostasoon nähden, joten sillä ei ole viivaa tasossa",
|
||||
"Top level": "Ylin taso",
|
||||
"Two Distances": "Kaksi etäisyyttä",
|
||||
"UNFOLD": "LEVITÄ",
|
||||
"UNSTITCH": "PURA OMMEL",
|
||||
"Use axis direction only": "Käytä vain akselin suuntaa",
|
||||
"Variable radius": "Muuttuva säde",
|
||||
"View": "Näkymä",
|
||||
"Walnut": "Pähkinäpuu",
|
||||
"ai.http.allow": "Salli suojaamaton HTTP",
|
||||
"ai.http.confirm": "Salli tälle päätepisteelle",
|
||||
"ai.http.warning": "API-avain ja keskustelu lähetetään salaamattomina. Kuka tahansa, jolla on pääsy verkkoon, voi lukea tai muuttaa niitä. Käytä tätä vain verkossa, johon luotat.",
|
||||
"ai.prefs.instructions": "Omat ohjeet",
|
||||
"ai.prefs.instructions_note": "Lähetetään agentille jokaisen keskustelun alussa SolidCraftin omien ohjeiden jälkeen: miten haluat työskennellä ja mitä sääntöjä noudatetaan. Muutos tulee voimaan seuraavasta keskustelusta (Yhdistä tai Uusi keskustelu).",
|
||||
"browser.canvas_locked": "Pohjakuva on lukittu. Avaa lukitus ensin.",
|
||||
"cmd.inspect_colored_components": "Komponenttien värit",
|
||||
"cmd.model_body_features": "Luettele kappaleen piirteet",
|
||||
"cmd.panel.action": "Suorita paneelin otsikon toiminto",
|
||||
"cmd.panel.focus.next": "Kohdista seuraavaan paneeliin",
|
||||
"cmd.panel.focus.previous": "Kohdista edelliseen paneeliin",
|
||||
"cmd.panel.move.next_zone": "Siirrä paneeli seuraavalle alueelle",
|
||||
"cmd.panel.sash.down": "Siirrä paneelien jakajaa alas",
|
||||
"cmd.panel.sash.left": "Siirrä paneelien jakajaa vasemmalle",
|
||||
"cmd.panel.sash.reset": "Palauta kohdistetun paneelin jakaja",
|
||||
"cmd.panel.sash.right": "Siirrä paneelien jakajaa oikealle",
|
||||
"cmd.panel.sash.up": "Siirrä paneelien jakajaa ylös",
|
||||
"cmd.panel.toggle": "Kohdistettu paneeli päälle/pois",
|
||||
"cmd.pointcloud_reload": "Lataa pistepilvi uudelleen",
|
||||
"cmd.render.coat.bind": "Tallenna kirkaslakka",
|
||||
"cmd.render.coat.unbind": "Poista kirkaslakka",
|
||||
"cmd.render.surface.import": "Tuo pintakartat",
|
||||
"cmd.render.texture.list": "Luettele upotetut pintakuvat",
|
||||
"cmd.render.texture.remove": "Poista upotettu pintakuva",
|
||||
"cmd.sketch.replace": "Korvaa luonnos",
|
||||
"cmd.sketch_move_points": "Siirrä luonnospisteitä",
|
||||
"cmd.sketch_point_divide": "Jaa käyrä",
|
||||
"cmd.view.navigator": "Mukautettu näkymänavigaattori",
|
||||
"cmd.view.render_live": "Keskeytä tai jatka reaaliaikaista renderöintiä",
|
||||
"cmd.view_ribbon_tabs_only": "Vain valintanauhan välilehdet",
|
||||
"cmd.view_tool_zoom": "Zoomaa vetämällä",
|
||||
"dialog.cylinder_face_tip": "Mallin pinta tai kärkipiste, jonka päällä sylinteri seisoo: napsauta tätä ja napsauta sitten sitä näkymässä. Jos tämä jätetään tyhjäksi, sijainnin määräävät X, Y ja Z.",
|
||||
"dialog.primitive_point_tip": "Piste, joka täyttää X:n, Y:n ja Z:n: napsauta tätä ja napsauta sitten mallin kärkipistettä, särmää tai pintaa, luonnoksen pistettä tai origoa.",
|
||||
"dialog.reference_edge_tip": "Valitun pinnan suora särmä, josta alla oleva etäisyys mitataan: napsauta tätä ja napsauta sitten särmää.",
|
||||
"document.newer_file": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, tämä versio lukee enintään muodon {reads}). Jos jotain puuttuu, päivitä.",
|
||||
"document.newer_file_change": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, jossa `{change}`, jota tämä versio ei tunne). Jos jotain puuttuu, päivitä.",
|
||||
"document.newer_file_not_read": "Ei luettu: {what}. Tallennus säilyttää sen.",
|
||||
"file.open.portal_fallback.no_portal_service": "Avautunut tiedostonvalitsin ei ole työpöydän oma: työpöydän portaalipalvelu ei ole käytettävissä.",
|
||||
"file.open.portal_fallback.no_session_bus": "Avautunut tiedostonvalitsin ei ole työpöydän oma: D-Bus-istuntoväylää ei ole.",
|
||||
"file.open.portal_fallback.xwayland": "Avautunut tiedostonvalitsin ei ole työpöydän oma: XWaylandissa portaali hylkää yläikkunan.",
|
||||
"file_load.inserted": "Lisätty {name}",
|
||||
"file_load.opened": "Avattu {name}",
|
||||
"file_load.opened_in_tab": "{name} avattu uudessa välilehdessä",
|
||||
"file_load.reloading_cloud": "Ladataan pistepilveä uudelleen",
|
||||
"help.sketch.point.divide": "Sijoittaa pisteitä, jotka jakavat valitut käyrät yhtä pitkiin osiin. Pisteet pysyvät kullakin käyrällä ja säilyttävät välinsä, kun käyrä muuttuu.",
|
||||
"help.view.ribbon_tabs_only": "Kutistaa valintanauhan välilehtiriviksi, jolloin näkymä saa paneelien viemän tilan. Napsauta välilehteä, niin sen paneelit näkyvät näkymän päällä; valitse tämä uudelleen, niin koko valintanauha pysyy näkyvissä.",
|
||||
"hole_folder.applies_to": "Muutokset koskevat kansion {folder} {count} reikää; kukin pysyy paikallaan.",
|
||||
"nav.drag_keeps_mode.help": "Keski- tai oikean painikkeen veto jatkaa kiertoa, panorointia tai zoomausta sellaisena kuin se alkoi, kunnes painike vapautetaan, riippumatta siitä, mitä näppäimiä sillä välin painetaan tai vapautetaan. Pois: painettuina olevat näppäimet ratkaisevat vedon joka hetkellä.",
|
||||
"nav.drag_keeps_mode.label": "Veto säilyttää navigointitavan, jolla se alkoi",
|
||||
"nav.orbit_cursor_wrap.help": "Kun kiertoveto saavuttaa näkymän reunan, kohdistin siirtyy vastakkaiseen reunaan ja kierto jatkuu. Pois: veto pysähtyy näytön reunaan. Ei vaikutusta Waylandissa eikä selaimessa.",
|
||||
"nav.orbit_cursor_wrap.label": "Siirrä kohdistin vastakkaiseen reunaan kierrettäessä",
|
||||
"navbar.zoom_hold_tip": "Zoomaa (pidä pikanäppäin painettuna)",
|
||||
"prefs.background_choose": "Valitse kuva…",
|
||||
"prefs.background_image": "Kuva",
|
||||
"prefs.background_unreadable": "Taustakuvaa ei voitu lukea",
|
||||
"prefs.browser_body_features": "Piirteet kappaleiden alla",
|
||||
"prefs.browser_body_features_note": "Kunkin kappaleen alle selaimessa avautuvat aikajanan piirteet, joilla se on rakennettu, aikajanan järjestyksessä.",
|
||||
"prefs.browser_rows_start_closed": "Selaimen rivit ovat aluksi suljettuina",
|
||||
"prefs.browser_rows_start_closed_note": "Selaimen sijoitukset ja kansiot ovat aluksi suljettuina, joten mallista, jossa on paljon komponentteja, näkyvät ylimmän tason rivit. Kukin avautuu nuolestaan, Laajenna kaikki -toiminnolla tai Etsi selaimesta -toiminnolla, joka avaa vain rivit kappaleeseen asti ja sulkee taas ne, jotka edellinen haku avasi. 3D-näkymässä valittu kappale jättää selaimen ennalleen. Kun tämä on pois päältä, kansiot ovat aluksi auki, sijoitukset noudattavat Komponentit alkavat avoimina -asetusta, ja 3D-näkymässä tehty valinta avaa selaimen kappaleeseen asti kuten ennenkin.",
|
||||
"prefs.command_dialog_values": "Komentoikkunat",
|
||||
"prefs.design_tools.hole_group": "Ryhmitä yhdessä tehdyt reiät",
|
||||
"prefs.design_tools.hole_group_help": "Kerralla tehdyt reiät ovat historiassa samassa kansiossa, joka on aluksi suljettu; avaa kansio, jos haluat muokata reikää erikseen.",
|
||||
"prefs.design_tools.hole_several": "Useita reikiä yhdelle pinnalle",
|
||||
"prefs.design_tools.hole_several_help": "Pinnan muun kohdan valitseminen lisää uuden reiän; reiän valitseminen poistaa sen.",
|
||||
"prefs.extrude_arrow_face_stops": "Pursotusnuoli pysähtyy pintoihin",
|
||||
"prefs.extrude_arrow_face_stops_note": "Pursotuksen etäisyysnuolen vetäminen pysähtyy profiilin suuntaisiin tasopintoihin, kuten leikattavan kappaleen vastakkaiseen pintaan, ja korostaa pinnan ääriviivan. Pois: nuoli liikkuu vain tasavälein.",
|
||||
"prefs.ground_shadow_follows_model": "Maavarjo seuraa mallia",
|
||||
"prefs.ground_shadow_follows_model_help": "Sijoita varjo alimman näkyvän kappaleen alle. Kun tämä on pois päältä, korotettujen mallien varjo pysyy origon maatasolla.",
|
||||
"prefs.history_bar_groups": "Kokoa historiapalkki ryhmiksi",
|
||||
"prefs.history_bar_groups_note": "Pitkässä historiassa yhden komponentin peräkkäiset piirteet kootaan yhdeksi merkiksi, ja piirrekansiot ovat aluksi kutistettuina. Lisätty STEP-kokoonpano sijoitetaan ryhmäkansioon. Pois: jokainen piirre näytetään, kansiot ovat aluksi auki eikä kansiota luoda.",
|
||||
"prefs.history_folder_kind_icon": "Kansion kuvake sen piirteistä",
|
||||
"prefs.history_folder_kind_icon_note": "Historiakansio, jonka kaikki piirteet ovat samaa lajia, näyttää niiden kuvakkeen.",
|
||||
"prefs.history_group_joints": "Ryhmitä nivelet historiassa",
|
||||
"prefs.history_group_joints_note": "Kaksi tai useampi nivel näkyy yhtenä painikkeena lukumäärineen, ja napsautus näyttää jokaisen. Pois: jokainen nivel näytetään.",
|
||||
"prefs.keep_unsaved_also_asks": "Kysy silti, kun välilehti suljetaan",
|
||||
"prefs.keep_unsaved_also_asks_tip": "Kun tämä on pois päältä, välilehti sulkeutuu ilman selaimen kyselyikkunaa, kun jokaisen tallentamattoman mallin kopio on ajan tasalla.",
|
||||
"prefs.keep_unsaved_in_browser": "Säilytä tallentamaton työ tässä selaimessa",
|
||||
"prefs.keep_unsaved_in_browser_tip": "Jokaisen avoimen mallin tallentamattomat muutokset pidetään ajan tasalla tämän selaimen yksityisessä tallennustilassa. Seuraavalla käynnillä niitä tarjotaan palautettaviksi.",
|
||||
"prefs.layout_page.hover_panel_menus": "Vaihda paneelivalikkoa osoittimen ollessa päällä",
|
||||
"prefs.layout_page.hover_panel_menus_help": "Avaa valintanauhan paneelin valikko napsauttamalla sen nimeä. Vaihda valikkoa viemällä osoitin toisen paneelin nimen päälle. Sulje valikko siirtämällä osoitin valintanauhan ja avoimen valikon ulkopuolelle.",
|
||||
"prefs.move_drag_highlights": "Korosta osoittimen alla oleva kohde, kun Siirrä-toiminnon nuolilla vedetään komponenttia",
|
||||
"prefs.move_drag_highlights_tip": "Pois: kun Siirrä-toiminnon nuolilla vedetään komponenttia, osoittimen alla olevaa kohdetta ei korosteta, mikä pitää suuret kokoonpanot sulavina. Kun leikkaus tai analyysi on näkyvissä, kohde korostetaan silti, eikä niveliään pitkin vedetty osa koskaan korosta sitä.",
|
||||
"prefs.panel_layout.recovery": "Tallennettuja paneeliasetteluja ei voitu palauttaa. Tallennetut tiedot säilytetään; Perinteinen on edelleen käytettävissä.",
|
||||
"prefs.picker.carousel": "Karuselli",
|
||||
"prefs.picker.change": "Vaihda",
|
||||
"prefs.picker.clear": "Tyhjennä suodattimet",
|
||||
"prefs.picker.day": "Päiväteema",
|
||||
"prefs.picker.empty": "Mikään teema ei vastaa näitä suodattimia.",
|
||||
"prefs.picker.follow": "Seuraa järjestelmää",
|
||||
"prefs.picker.grid": "Ruudukko",
|
||||
"prefs.picker.high": "Suuri kontrasti",
|
||||
"prefs.picker.high_note": "{source} · Suuri kontrasti",
|
||||
"prefs.picker.hint": "Vaihtuu käyttöjärjestelmän mukana.",
|
||||
"prefs.picker.import": "Tuo teematiedosto…",
|
||||
"prefs.picker.imported": "Tuotu",
|
||||
"prefs.picker.night": "Yöteema",
|
||||
"prefs.picker.one": "Yksi teema",
|
||||
"prefs.picker.pick": "Valitse alta",
|
||||
"prefs.picker.standard": "Vakio",
|
||||
"prefs.remember_dialog_values": "Muista komentoikkunoiden arvot",
|
||||
"prefs.remember_dialog_values.help": "Oletuksena pois päältä. Kun tämä on käytössä, onnistuneen komennon turvalliset arvot ja asetukset otetaan uudelleen käyttöön, kun avaat saman komennon seuraavan kerran, kunnes SolidCraft suljetaan.",
|
||||
"prefs.render_legacy_image_defaults": "Käytä aiempia renderöintikuvan oletuksia",
|
||||
"prefs.render_legacy_image_defaults_help": "Säilytä aiempi manuaalinen työnkulku: uudet asetelmat käyttävät CPU-rasterointia, CPU-säteenjäljitys säilyttää kiinteän näytteistyksen ja avaat renderöidyt tulokset itse. Sovita näkymä uudelleen Renderöintiin siirryttäessä, vaihda kuvakulmia palauttamatta niiden kameroita ja käytä aiempaa rajausta ja navigointia. Poista automaattinen reaaliaikainen kuvapalaute käytöstä. Tallennetut asetelmat eivät muutu.",
|
||||
"prefs.ribbon_tabs_only": "Näytä vain valintanauhan välilehdet",
|
||||
"prefs.ribbon_tabs_only_note": "Valintanauha kutistuu välilehtiriviksi. Välilehden napsauttaminen näyttää sen paneelit näkymän päällä, kunnes komento alkaa tai osoitin poistuu niiden päältä. Näkymä-välilehden Vain valintanauhan välilehdet -komento tai sen pikanäppäin vaihtaa tämän.",
|
||||
"prefs.select_hidden_edges": "Valitse piilosärmiä tilassa Varjostettu, piilosärmät näkyvissä",
|
||||
"prefs.select_hidden_edges_help": "Valitse särmiä pintojen läpi, kun Varjostettu, piilosärmät näkyvissä on käytössä, myös pyöristyksessä. Pois: säilyttää version v0.30.1 valintatoiminnan. Piilotettuja kappaleita ei voi valita.",
|
||||
"prefs.shared_panel_layout": "Käytä jaettua paneeliasettelua",
|
||||
"prefs.shared_panel_layout_help": "Näytä nykyiset Selain, Historia ja Avustaja siirrettävissä paneeleissa. Muutos tulee voimaan, kun asetukset otetaan käyttöön.",
|
||||
"prefs.sketch.ellipse_axes": "Piirrä ellipsit akseleineen",
|
||||
"prefs.sketch.ellipse_axes_note": "Iso- ja pikkuakseli apuviivoina, jotta puoliakseleille ja akseleille voi lisätä mitat. Pois: pelkkä ellipsi, ja D sen päällä antaa sen pienemmän säteen riippumatta siitä, mistä arvo vedetään, piirrettynä ja R-merkittynä kuten ennenkin.",
|
||||
"prefs.sketch.escape_twice_finishes": "Kaksi Esc-painallusta lopettaa luonnoksen",
|
||||
"prefs.sketch.escape_twice_finishes_note": "Kaksi nopeaa Esc-painallusta lopettaa luonnoksen. Pois: Esc vain peruuttaa työkalun tai valinnan, ja Lopeta luonnos lopettaa luonnoksen.",
|
||||
"prefs.sketch_mirror_auto_line": "Käytä ennalta valittua apuviivaa peilaukseen",
|
||||
"prefs.tab_double_click_renames": "Nimeä mallin välilehti uudelleen kaksoisnapsauttamalla",
|
||||
"prefs.tab_double_click_renames_note": "Kirjoita uusi nimi ja säilytä se painamalla Enter tai napsauttamalla muualla, tai peruuta painamalla Esc. Pois: välilehden kaksoisnapsautus suurentaa ikkunan kuten palkin tyhjän osan kaksoisnapsautus.",
|
||||
"recovery.open_saved": "Avaa tallennettu versio",
|
||||
"recovery.restore": "Palauta",
|
||||
"recovery.restore_intro": "Muutokset, joita ei ollut tallennettu, kun tämä sivu viimeksi suljettiin, on säilytetty tässä selaimessa.",
|
||||
"recovery.restore_row": "{name}, muutettu {when}",
|
||||
"recovery.restore_title": "Palautetaanko tallentamattomat mallit?",
|
||||
"recovery.restore_title_one": "Palautetaanko tallentamaton malli: {name}, muutettu {when}?",
|
||||
"render.adaptive.pilot_progress": "Mukautuvat pilotit · {current}/{target} kierrosta",
|
||||
"render.adaptive.preview_help": "Pilottikuvat ohjaavat jakoa, eikä niitä sisällytetä lopulliseen kuvaan. Tuotantoiteraatiot käyttävät erillistä budjettia kullekin pikselille; iteraatioiden määrä ei vastaa tasaista näytemäärää pikseliä kohden.",
|
||||
"render.adaptive.production_progress": "Mukautuva tuotanto · {current}/{target} iteraatiota",
|
||||
"render.aging_raytrace_help": "Vanhentaminen tehdään CPU-säteenjäljityksellä ja GPU-polunjäljityksellä. Rasterointi säilyttää kaavamaisen renderöinnin.",
|
||||
"render.appearance_editor.discard_saved_edit": "Hylkää tallentamattomat optiset muokkaukset",
|
||||
"render.batch_current_settings_help": "Käyttää nykyisiä kuva-asetuksia kunkin tallennetun kuvakulman kameran kanssa, yksi kuvakulma kerrallaan.",
|
||||
"render.batch_owner_limit": "Enintään 32 asiakirjan kuvakulmajonoa voidaan säilyttää. Viimeistele tai peruuta ensin jokin olemassa oleva jono.",
|
||||
"render.batch_preparing": "Valmistellaan seuraavaa kuvakulmaa",
|
||||
"render.batch_waiting_worker": "Odotetaan renderöinnin valmistelua",
|
||||
"render.caustics_depth": "Kaustiikkapolun syvyys",
|
||||
"render.caustics_enable": "Lasin kaustiikka piste- ja suuntavaloista",
|
||||
"render.caustics_help": "Fotonien määrä vaikuttaa kohinaan ja laskennan määrään. Keruusäde vaihtaa terävät yksityiskohdat tasaisempaan arvioon. CPU vaatii epäsuoran valaistuksen.",
|
||||
"render.caustics_path_required": "CPU-kaustiikka vaatii epäsuoran valaistuksen.",
|
||||
"render.caustics_photons": "Fotonit",
|
||||
"render.caustics_radius": "Keruusäde",
|
||||
"render.check_glass_cpu": "CPU tukee varmennettuja tasomaisia lasilevyjä ja akselien suuntaisia laatikoita. Yleiset suljetut verkot, sisäkkäisyys ja sisäinen sironta vaativat epäsuoran valaistuksen. Suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta.",
|
||||
"render.check_glass_gpu": "GPU tukee varmennettuja erillisiä lasikappaleita tai nimenomaista varmennettua sisäkkäisyyttä. Sisäkkäisyys vaatii kuperat ulommat kappaleet, kokonaan niiden sisällä olevat sisemmät kappaleet eikä toisiaan koskettavia rajapintoja.",
|
||||
"render.check_media_remaining": "Tiheysruudukot, siirtymäkartoitus, täysi spektri-integrointi ja polarisaation eteneminen eivät edelleenkään ole käytettävissä.",
|
||||
"render.check_media_scope": "Aktiivinen sisäinen sironta vaatii suljetun lasiväliaineen, suorien varjojen läpäisyn pois päältä ja kohinanpoiston pois päältä. GPU vaatii lisäksi yhden Fresnel-kerrostuman. Aktiivista ulkoista ilmakehää ei voi yhdistää lasiin eikä pintojen kohinanpoistoon.",
|
||||
"render.coat_discard": "Hylkää lakan muokkaus",
|
||||
"render.coat_pending_edit": "Tätä lakan muokkausta ei ole tallennettu. Tallenna se kuvakulman mukana tai hylkää se.",
|
||||
"render.coat_remove": "Poista tallennettu lakka",
|
||||
"render.coat_remove_note": "Poistaa tämän kohteen lakan kuvakulmasta. Geometria, ulkoasut ja fysikaaliset materiaalit pysyvät ennallaan.",
|
||||
"render.coat_remove_title": "Poistetaanko tallennettu kirkaslakka?",
|
||||
"render.coat_revalidate_saved": "Tallennettu lakka on muuttunut. Hylkää tämä muokkaus ja avaa kohde uudelleen ennen kuin muokkaat sitä taas.",
|
||||
"render.coat_save": "Tallenna lakka kuvakulmaan",
|
||||
"render.coat_saved_help": "Kirkaslakka lisää kiiltävän kerroksen läpinäkymättömille pinnoille. Tallenna se kuvakulman mukana. Lasi ei voi jakaa tätä kohdetta. Myöskään CPU:n mukautetut heijastus- ja anisotropiaohitukset eivät voi jakaa sitä.",
|
||||
"render.cpu_celestial_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella · polun syvyys {depth} · {spp} SPP · jäljitetyt varjot · siemen {seed} · 1 ensisijainen näyte",
|
||||
"render.cpu_celestial_adopt_label": "CPU-säteenjäljitys epäsuoralla valaistuksella",
|
||||
"render.cpu_celestial_path_required": "Äärellinen aurinko ja liukuväritaivas käyttävät CPU:n epäsuoraa valaistusta.",
|
||||
"render.cpu_celestial_switch": "Säilytä tämä aurinko tai taivas valitsemalla CPU-säteenjäljitys epäsuoralla valaistuksella.",
|
||||
"render.cpu_celestial_unportable": "Säilytä GPU-polunjäljitys nykyisillä läpäisynäytteiden asetuksilla.",
|
||||
"render.cpu_constant_direct_help": "Ilman epäsuoraa valaistusta tämä vakiolähde voi näkyä heijastuksissa ja lasin läpi. Se ei valaise läpinäkymättömiä pintoja. Ota epäsuora valaistus käyttöön, jos haluat käyttää sitä näkymän valaistukseen.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Polun syvyys {depth}; progressiivinen näytteistys {spp} näytettä/pikseli (siemen {seed}); yksi ensisijainen säde näytettä kohden; jäljitetyt varjot. Kuvan koko, CPU-budjetti ja muut asetukset säilyvät.",
|
||||
"render.cpu_denoise_help": "Vähentää kohinaa jokaisessa kuvan päivityksessä. Se voi pehmentää hienoja yksityiskohtia ja syväterävyyden reunoja. Raaka-EXR pysyy muuttumattomana. Valmiin kuvan säätäminen käyttää sen raakakuvaa eikä tee kohinanpoistoa uudelleen.",
|
||||
"render.cpu_fixed_retained": "Kiinteä näytteistys säilytetty",
|
||||
"render.cpu_glass_enable_path": "Ota epäsuora valaistus käyttöön",
|
||||
"render.cpu_indirect_method_required": "Valitse CPU-säteenjäljitys ottaaksesi tämän epäsuoran valaistuksen tilan käyttöön.",
|
||||
"render.cpu_maps_saved_help": "CPU-renderöinnit voivat käyttää tallennettuja läpinäkymättömiä karttoja: valitse pinta tästä ja valitse sitten Tallenna kartat kuvakulmaan. Epäsuoralla valaistuksella lasi voi olla toisessa kappaleessa; kartoitettu kappale ei voi käyttää lasia. Valittujen heijastusten, anisotropian ja proseduraalisten pintatehosteiden on oltava pois päältä. Väliaikaiset karttatiedostopolut käyttävät GPU:ta.",
|
||||
"render.cpu_material_certification": "CPU:n tarkistus varmentaa tuetun lasigeometrian ennen renderöintiä. Suljettu lasi käyttää geometrista etäisyyttä.",
|
||||
"render.cpu_opaque_hint": "CPU-säteenjäljitys käyttää läpinäkymätöntä taustaa.",
|
||||
"render.cpu_path_depth": "Polun syvyys",
|
||||
"render.cpu_path_help": "Lisää valon heijastelun läpinäkymättömien pintojen välillä ja tuettujen lasimuotojen läpi. Vaatii progressiivisen renderöinnin ja jäljitetyt varjot. Polun syvyys laskee varjostetut pinnat, lasin rajapinnat ja sirontatapahtumat ensimmäisestä osumasta alkaen (1–16). Syvyys 1 antaa suoran valaistuksen läpinäkymättömille pinnoille; suuremmat arvot sallivat useampia heijastuksia tai lasin läpäisyjä ja voivat kestää kauemmin. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
|
||||
"render.cpu_path_lighting": "Epäsuora valaistus",
|
||||
"render.cpu_path_optics_reason": "Asetuksesi säilytetään. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
|
||||
"render.cpu_progressive_adopt": "Käytä progressiivista näytteistystä",
|
||||
"render.cpu_progressive_adopt_help": "Progressiivinen näytteistys käyttää yhtä kameranäytettä kierrosta kohden. Kuvan koko, valot ja heijastusasetukset pysyvät ennallaan.",
|
||||
"render.cpu_progressive_always": "Progressiivinen näytteistys on aina käytössä",
|
||||
"render.cpu_progressive_reflection_adopt": "Aseta valittujen heijastusten yrityskerroiksi 1 ennen progressiivisen näytteistyksen käyttöönottoa.",
|
||||
"render.cpu_quality_budget_help": "Muuttaa koon, tuotantonäytteiden ylärajan, siemenen ja CPU-resurssiprofiilin. Kamera, objektiivi, valaistus, GI-syvyys ja tehosteet säilyvät.",
|
||||
"render.cpu_quality_progressive_required": "Ota alla progressiivinen näytteistys käyttöön ennen CPU-budjetin valitsemista.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · näytteitä enintään {samples} · siemen {seed} · {profile}",
|
||||
"render.cpu_sky_bounds": "Taivaan kierron on oltava äärellinen ja välillä ±1 000 000 radiaania; voimakkuuden on oltava 0–100.",
|
||||
"render.cpu_studio_enable": "Ota epäsuora valaistus käyttöön tälle asetelmalle",
|
||||
"render.cpu_studio_enable_apply": "Ota epäsuora valaistus käyttöön ja käytä",
|
||||
"render.cpu_studio_indirect_help": "Tämä aurinkoasetelma vaatii epäsuoran valaistuksen syvyydellä 8, jäljitetyt varjot ja yhden kameranäytteen kierrosta kohden. Jos progressiivinen renderöinti on pois päältä, se aloitetaan 16 näytteellä pikseliä kohden siemenellä 0. Nykyiset progressiiviset asetukset ja resurssiprofiili säilyvät. Yhteensopimattomat asetukset keskeyttävät käyttöönoton muuttamatta niitä.",
|
||||
"render.cpu_studio_radiance_limit": "Tämän asetelman pintasäteily on {radiance}, mikä ylittää CPU:n rajan 64. Laadittu valaistus säilytetään; valitse toinen asetelma tai GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU:n pinnanalainen sironta vaatii kohinanpoiston pois päältä. Asetuksesi säilytetään.",
|
||||
"render.cpu_subsurface_help": "Sironta ohjaa valoa uudelleen suljetun lasin sisällä. Positiiviset tiheydet vaativat epäsuoran valaistuksen ja kohinanpoiston pois päältä. Tiheys ilmoitetaan törmäyksinä millimetriä kohden; 0 ei lisää sirontaa. Positiivinen vaihepainotus suosii eteenpäin suuntautuvia suuntia, negatiivinen taaksepäin suuntautuvia.",
|
||||
"render.cpu_transparent_help": "Läpinäkyvä PNG poistaa taustan ja säilyttää reunojen peittävyyden. Kappaleiden ja pintojen peittävyyden on pysyttävä 100 %:ssa.",
|
||||
"render.cpu_volume_enable_path": "Ota epäsuora valaistus käyttöön",
|
||||
"render.cpu_volume_enable_path_plan": "CPU-säteenjäljitys · polun syvyys {depth} · {spp} näytettä/pikseli · siemen {seed} · jäljitetyt varjot. Laadittu ilmakehä ja kuvabudjetti säilyvät.",
|
||||
"render.cpu_volume_help": "CPU:n ilmakehä käyttää epäsuoraa valaistusta. Nollaekstinktio säilyttää tallennetun laatikon ilman sumua.",
|
||||
"render.cpu_volume_method_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella",
|
||||
"render.cpu_volume_method_incompatible": "Näitä valo-, näytteistys- tai ilmakehäasetuksia ei voi siirtää tähän menetelmään. Asetuksesi säilytetään.",
|
||||
"render.cpu_volume_path_required": "Ota epäsuora valaistus käyttöön, jos haluat käyttää CPU:n ilmakehää.",
|
||||
"render.directional_sun_limit": "GPU-valaistus tukee yhteensä kahdeksaa suuntavalolähdettä, joista enintään yksi voi olla pehmeä aurinko. Lisää uusi kaukainen valonlähde monistamalla kova suuntavalo.",
|
||||
"render.glass_abbe": "Abben luku",
|
||||
"render.glass_dispersion": "Kromaattinen dispersio",
|
||||
"render.glass_film": "Ohutkalvointerferenssi",
|
||||
"render.glass_film_ior": "Kalvon IOR",
|
||||
"render.glass_film_thickness": "Kalvon paksuus",
|
||||
"render.glass_model_enable": "Edistynyt lasimalli",
|
||||
"render.glass_model_help": "Lasin karheus sirottaa läpäiseviä säteitä. Dispersio ja kalvon väri käyttävät kolmea aallonpituuskaistaa. Karheat levyt vaativat suorien läpäisevien varjojen olevan pois päältä.",
|
||||
"render.glass_roughness": "Lasin karheus",
|
||||
"render.gpu_job_shader_wait": "Varjostimien valmistelu voi kestää minuutteja",
|
||||
"render.gpu_path_crashed": "SolidCraft sulkeutui viimeksi, kun tätä valmisteltiin tällä näytönohjaimella, todennäköisesti näytönohjaimen ajurin sisällä, joten valmistelua ei käynnistetä uudelleen automaattisesti. Valitse sen sijaan CPU-säteenjäljitys tai yritä uudelleen (myös ajurin päivitys yrittää uudelleen).",
|
||||
"render.gpu_path_not_started": "GPU-polunjäljittimen valmistelu ei ole alkanut.",
|
||||
"render.gpu_path_retry": "Yritä uudelleen",
|
||||
"render.light_space_world": "Maailmakoordinaatiston valot",
|
||||
"render.light_space_world_help": "Valojen sijainnit ja suunnat käyttävät maailmakoordinaatteja. Kameran kiertäminen ei siirrä niitä.",
|
||||
"render.live.adaptive": "Reaaliaikainen · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "Renderöintipyyntö muuttui reaaliaikaisen renderöinnin pysähtyessä. Aloita lopullinen kuva uudelleen.",
|
||||
"render.live.final_waiting": "Valmistellaan lopullista kuvaa · vapautetaan reaaliaikaisen renderöinnin resursseja",
|
||||
"render.live.invalid_size": "Reaaliaikainen renderöinti vaatii positiivisen tulosteen leveyden ja korkeuden.",
|
||||
"render.live.pause": "Keskeytä reaaliaikainen",
|
||||
"render.live.paused": "Reaaliaikainen · Keskeytetty",
|
||||
"render.live.paused_outdated": "Reaaliaikainen · Keskeytetty · Vanhentunut",
|
||||
"render.live.quality_help": "Vuorovaikutteinen laatu: enintään 512 pikseliä pisimmällä sivulla. Käyttää valittua renderöijää ja sen tukemia tehosteita. Lopullinen tarkkuus, asetukset ja vienti pysyvät erillään.",
|
||||
"render.live.refused": "Reaaliaikainen ei käytettävissä: {reason}",
|
||||
"render.live.resume": "Jatka reaaliaikaista",
|
||||
"render.live.samples": "Reaaliaikainen · {width} × {height} · {current}/{target} näytettä",
|
||||
"render.live.source_current": "{method} · Reaaliaikainen",
|
||||
"render.live.source_outdated": "{method} · Edellinen kuva",
|
||||
"render.live.source_outdated_help": "Näyttää viimeksi muodostetun kuvan, kun reaaliaikainen renderöinti päivittyy tai ei ole käytettävissä. Se voi poiketa nykyisestä näkymästä tai kuva-asetuksista.",
|
||||
"render.live.source_settings_help": "Avaa Tuloste muokataksesi renderöijän ja kuvan asetuksia.",
|
||||
"render.live.stopped": "Reaaliaikainen renderöinti pysäytetty",
|
||||
"render.live.updating": "Reaaliaikainen · Vuorovaikutteinen laatu (enintään 512 px) · Päivitetään",
|
||||
"render.live.waiting_canvas": "Odotetaan renderöinnin piirtoaluetta",
|
||||
"render.live.worker_stopped": "Reaaliaikaisen renderöinnin kaappausprosessi pysähtyi ennen tuloksen palauttamista",
|
||||
"render.material_cpu_glass_only": "Väliaikaiset kuvatiedostokartat vaativat GPU:n. CPU voi käyttää tallennettuja sisäänrakennettuja pintakarttoja.",
|
||||
"render.material_cpu_optics_limits": "Ota epäsuora valaistus käyttöön tuettuja suljettuja lasimuotoja, varmennettua sisäkkäisyyttä ja pinnanalaista sirontaa varten. Ohuet levyt voivat päästää varjot läpi. Muussa tapauksessa käytä tasomaista levyä tai akselien suuntaista laatikkoa. Toisiaan koskettavia muotoja ja suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta. Arvosi säilytetään.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Tallennettua CPU-optiikkaa ei voi yhdistää vanhanmallisiin väliaikaisiin optisiin sidontoihin. Tallenna tarkistettu optiikka tai poista ristiriitainen väliaikainen sidonta.",
|
||||
"render.material_raytrace_required": "Fysikaalinen lasi vaatii CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
|
||||
"render.material_saved_edit_pending": "Näitä optisia muokkauksia ei ole tallennettu. Tallenna tarkistetut arvot, poista tallennettu määritys tai hylkää muokkaukset. Vanhanmalliset väliaikaiset sidonnat ja tukemattomat tehosteet eivät voi ohittaa tallennettua kohdetta.",
|
||||
"render.output_master_unavailable": "Valitse valmis kuva, jossa on raaka-EXR-data, viedäksesi EXR:n. Ota raaka-EXR käyttöön ennen uusien kuvien renderöintiä.",
|
||||
"render.output_presentation_limits": "CPU:n kuvatehosteet ja EXR-tuloste tukevat kuvia, joiden leveys tai korkeus on enintään 8192 pikseliä ja joissa on yhteensä enintään 16 777 216 pikseliä.",
|
||||
"render.output_raytrace_only": "Kuvatehosteet vaativat CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
|
||||
"render.preparation_cancelled": "Renderöinnin valmistelu peruutettu",
|
||||
"render.preparation_checking": "Tarkistetaan renderöintiä",
|
||||
"render.preparation_retirement_failed": "Renderöinnin valmistelun siivous epäonnistui. Tämä ikkuna ei voi valmistella uutta kuvaa.",
|
||||
"render.preparation_retiring": "Viimeistellään renderöinnin valmistelua",
|
||||
"render.preparation_unavailable": "Renderöinnin valmistelu ei ole käytettävissä",
|
||||
"render.presentation_banner": "Esitystehosteet toimivat CPU- ja GPU-säteenjäljityksen kanssa. Tämän sivun avaaminen ei koskaan muuta menetelmää.",
|
||||
"render.quality_budget_help": "Asettaa koon, näytteet, syvyyden, siemenen ja kohinanpoiston. Muut kuva-asetukset säilyvät.",
|
||||
"render.quality_denoise_off": "Kohinanpoisto: Pois",
|
||||
"render.quality_denoise_on": "Kohinanpoisto: Päällä",
|
||||
"render.quality_summary": "{width} × {height} · {samples} näytettä · syvyys {depth} · siemen {seed} · {denoise}",
|
||||
"render.shot_lens_cpu_progressive_reason": "Ota progressiivinen näytteistys käyttöön Näytteistys-kohdassa ennen CPU:n syväterävyyden käyttöönottoa.",
|
||||
"render.shot_lens_raytrace_reason": "Syväterävyys on käytettävissä CPU-säteenjäljityksessä ja GPU-polunjäljityksessä.",
|
||||
"render.spot_axis": "Säteilysuunta",
|
||||
"render.spot_gpu_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä. Hyvin kapeat himmenemisalueet vaativat CPU-renderöinnin.",
|
||||
"render.spot_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä.",
|
||||
"render.spot_inner": "Sisempi puolikulma",
|
||||
"render.spot_outer": "Ulompi puolikulma",
|
||||
"render.stage_panels.grain_angle": "Syykulma",
|
||||
"render.surface_import.author": "Tekijä",
|
||||
"render.surface_import.busy": "Tuodaan kuvaa…",
|
||||
"render.surface_import.complete": "Valitse kuva ja anna sen tekijä, lähde ja lisenssi.",
|
||||
"render.surface_import.embedded": "Tuotu kuva",
|
||||
"render.surface_import.failed": "Kuvan tuonti ei valmistunut.",
|
||||
"render.surface_import.license": "Lisenssi / omistus",
|
||||
"render.surface_import.raytrace_required": "Kuvat vaativat CPU- tai GPU-säteenjäljityksen. Valitse se kohdassa Tuloste.",
|
||||
"render.surface_import.repeat_u": "U-toisto",
|
||||
"render.surface_import.repeat_v": "V-toisto",
|
||||
"render.surface_import.save": "Tallenna kuva kappaleeseen",
|
||||
"render.surface_import.saved_cpu": "Tallenna kartat, jotta voit käyttää niitä CPU-renderöinnissä.",
|
||||
"render.surface_import.source": "Lähde",
|
||||
"render.surface_import.title": "Tuo kuva",
|
||||
"render.volume_denoise_required": "Poista kohinanpoisto käytöstä, jos haluat käyttää ilmakehää.",
|
||||
"render.volume_glass_required": "Ilmakehää ei voi yhdistää lasiin tässä asetelmassa.",
|
||||
"render.volume_lighting_required": "Valitse ympäristö tai nimenomainen valoasetelma, jotta ilmakehä voidaan renderöidä.",
|
||||
"render.volume_single_stratum_required": "Käytä yhtä läpäisynäytettä ilmakehän renderöintiin.",
|
||||
"sketch.divide_hint": "Pisteet jakavat kunkin käyrän yhtä pitkiin osiin ja säilyttävät jaon, kun käyrä muuttuu.",
|
||||
"sketch.error.closed_curve_two_crossings": "suljettu käyrä tarvitsee kaksi leikkauskohtaa katkaisua varten",
|
||||
"sketch.error.nothing_to_break": "tässä ei ole katkaistavaa",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` on siirtymä, ja sen kulmat seuraavat sen lähdekäyriä: pyöristä tai viistä lähdekäyrien kulmat ennen siirtymää tai katkaise ensin siirtymän yhteys",
|
||||
"sketch.error.offset_trim": "`{id}` on siirtymä, ja sen päät seuraavat sen lähdekäyriä: muokkaa sen sijaan lähdekäyriä tai katkaise ensin siirtymän yhteys",
|
||||
"sketch.hint.arc.center_point": "Kaari: napsauta keskipistettä ja alkupistettä, kierrä kumpaan suuntaan tahansa ja napsauta loppupistettä tai kirjoita etumerkillinen kaarikulma",
|
||||
"sketch.hint.arc.three_point": "Kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä",
|
||||
"sketch.hint.arc3d": "3D-kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä (pisteet voivat olla luonnostason ulkopuolella)",
|
||||
"sketch.hint.break": "Katko: napsauta käyrää kohdasta, josta se jaetaan",
|
||||
"sketch.hint.circle.center": "Ympyrä: napsauta keskipistettä ja sitten ympyrän pistettä",
|
||||
"sketch.hint.constraint.generic": "Rajoite: valitse luonnoksen entiteetit",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Vaakasuora/pystysuora: valitse viiva, kaksi pistettä tai piste ja viiva; valitse viivan keskipiste pitämällä Shift painettuna",
|
||||
"sketch.hint.constraint.midpoint": "Keskipiste: valitse piste ja viiva tai kaksi viivaa, jolloin jälkimmäinen keskitetään ensimmäiseen",
|
||||
"sketch.hint.dimension": "Mitta: valitse yksi tai kaksi entiteettiä ja kirjoita arvo (tai sijoita mitta ensin napsauttamalla); muokkaa mittaa napsauttamalla sitä; Enter käyttää",
|
||||
"sketch.hint.extend": "Jatka: napsauta käyrää jatkettavan pään läheltä (Shift: napsauta kahta käyrää jatkaaksesi molempia niiden kohtaamispisteeseen)",
|
||||
"sketch.hint.fallback": "Napsauta luonnoksessa",
|
||||
"sketch.hint.line": "Viiva: napsauta pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
|
||||
"sketch.hint.line3d": "3D-viiva: napsauta maailman pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä (pisteet voivat olla luonnostason ulkopuolella)",
|
||||
"sketch.hint.open_done": "Avoin valmis",
|
||||
"sketch.hint.point3d": "3D-piste: napsauta 3D-pisteen paikkaa",
|
||||
"sketch.hint.rectangle.center": "Keskipistesuorakulmio: napsauta keskipistettä ja sitten kulmaa",
|
||||
"sketch.hint.rectangle.rounded": "Pyöristetty suorakulmio: napsauta kahta vastakkaista kulmaa (siirry Tab-näppäimellä säderuutuun kirjoittaaksesi kulmasäteen)",
|
||||
"sketch.hint.rectangle.three_point": "Kolmen pisteen suorakulmio: napsauta yhden sivun kahta pistettä ja sitten leveyttä",
|
||||
"sketch.hint.rectangle.two_point": "Suorakulmio: napsauta kahta vastakkaista kulmaa",
|
||||
"sketch.hint.slot": "Ura: napsauta molempia päitä ja sitten leveyttä",
|
||||
"sketch.hint.spline3d": "3D-splini: napsauta sovituspisteitä 3D:ssä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
|
||||
"sketch.hint.trim": "Katkaise: napsauta poistettavaa käyrän osaa tai vedä poistettavien osien yli",
|
||||
"ui.addToQuickAccess": "Lisää pikakäyttöön",
|
||||
"ui.close_tab": "Sulje välilehti",
|
||||
"ui.fair_iterations": "Iteraatiot",
|
||||
"ui.fair_keep_edges": "Säilytä särmät",
|
||||
"ui.fair_strength": "Voimakkuus",
|
||||
"ui.fair_surface_title": "SILOTA PINTA",
|
||||
"ui.fair_tolerance_default": "0.001 kappaleen koosta",
|
||||
"ui.first_dimension_scales": "Ensimmäinen mitta skaalaa piirustuksen",
|
||||
"ui.first_dimension_scales_tip": "Päällä: luonnoksen ensimmäinen pituusmitta skaalaa mittaamaansa piirustusta, joten sen muoto pysyy samana. Pois: mitta muuttaa vain mittaamansa kohteen kokoa.",
|
||||
"ui.heading_custom_view_navigator": "MUKAUTETTU NÄKYMÄNAVIGAATTORI",
|
||||
"ui.heading_divide_curve": "JAA KÄYRÄ",
|
||||
"ui.heading_press_pull": "TYÖNNÄ/VEDÄ",
|
||||
"ui.history_scope_all": "Näytä koko mallin piirteet",
|
||||
"ui.history_scope_all_shown": "Koko mallin piirteet",
|
||||
"ui.history_scope_pick": "Muokkaa komponenttia tai valitse komponentti selaimessa, niin näet vain sen piirteet",
|
||||
"ui.history_scope_show": "Näytä vain komponentin {name} ja sen sisältämien komponenttien piirteet",
|
||||
"ui.history_scope_shown": "Komponentin {name} ja sen sisältämien komponenttien piirteet",
|
||||
"ui.intersection_curve_second_pick": "Leikkauskäyrä: napsauta toista pintaa tai käyrää (aloita alusta hiiren kakkospainikkeella)",
|
||||
"ui.match_surface_title": "SOVITA PINTA",
|
||||
"ui.mcp_agents": "Agentit MCP:n kautta",
|
||||
"ui.mcp_compact": "Tiivis tuloste",
|
||||
"ui.mcp_compact_note": "MCP-palvelimen (solidcraft-cli mcp) kautta yhdistetyille agenteille: inspect_design tiivistää luonnokset, eikä työkalujen tuloksia muotoilla luettaviksi, joten ne käyttävät vähemmän tokeneita. Koskee muutoksen jälkeen aloitettuja agenttiistuntoja; myös --compact tai SOLIDCRAFT_MCP_COMPACT=1 ottaa sen käyttöön.",
|
||||
"ui.mesh_import_origin": "Siirrä origoon",
|
||||
"ui.mesh_import_origin_tip": "Keskitä verkko X- ja Y-suunnassa niin, että sen pohja on tasolla Z = 0. Kun asetus on pois päältä, verkko säilyttää tiedostoon tallennetut koordinaatit.",
|
||||
"ui.organize": "Järjestä",
|
||||
"ui.panel_fixture.apply": "Käytä",
|
||||
"ui.panel_fixture.cancel": "Peruuta",
|
||||
"ui.panel_fixture.caption": "Synteettinen paneelinäyte",
|
||||
"ui.panel_fixture.draft": "Säilytetty luonnos",
|
||||
"ui.panel_fixture.editor": "Näyte-editori",
|
||||
"ui.panel_fixture.explanation": "Tämä synteettinen sisältö säilyttää luonnoksensa, kun se on piilotettu tai siirretty.",
|
||||
"ui.panel_fixture.fill": "Täytä näyte",
|
||||
"ui.panel_fixture.finish": "Valmis",
|
||||
"ui.panel_fixture.finished": "Synteettinen tapahtuma päättyi",
|
||||
"ui.panel_fixture.footer": "Synteettiset toiminnot eivät muuta mallia.",
|
||||
"ui.panel_fixture.guard": "Estä peruutus, kun tapahtuma on käynnissä",
|
||||
"ui.panel_fixture.inline": "Upotettu näyte",
|
||||
"ui.panel_fixture.live": "Synteettinen tapahtuma käynnissä",
|
||||
"ui.panel_fixture.log": "Näyteloki",
|
||||
"ui.panel_fixture.pin": "Kiinnitetty näyte",
|
||||
"ui.panel_fixture.title": "{title} · {id}",
|
||||
"ui.panel_fixture.updates": "Palvelupäivitykset: {count}",
|
||||
"ui.panel_host.attention": "Vaatii huomiota",
|
||||
"ui.panel_host.bottom": "Alas",
|
||||
"ui.panel_host.close": "Sulje paneeli",
|
||||
"ui.panel_host.collapse": "Kutista paneeli",
|
||||
"ui.panel_host.expand": "Laajenna paneeli",
|
||||
"ui.panel_host.floating": "Kelluva",
|
||||
"ui.panel_host.focus": "Kohdista paneeliin",
|
||||
"ui.panel_host.focus_target": "Kohdista: {title}",
|
||||
"ui.panel_host.header": "Paneelin otsikko",
|
||||
"ui.panel_host.join": "Yhdistä välilehdet",
|
||||
"ui.panel_host.join_target": "Yhdistä välilehdet: {title}",
|
||||
"ui.panel_host.left": "Vasemmalle",
|
||||
"ui.panel_host.menu": "Paneelin toiminnot",
|
||||
"ui.panel_host.move": "Siirrä",
|
||||
"ui.panel_host.overflow": "Lisää paneeleja",
|
||||
"ui.panel_host.popout": "Avaa ikkunassa",
|
||||
"ui.panel_host.resize": "Muuta paneelin kokoa",
|
||||
"ui.panel_host.right": "Oikealle",
|
||||
"ui.panel_host.rule.availability": "Tämä paneeli ei ole käytettävissä.",
|
||||
"ui.panel_host.rule.bounds": "Paneelit eivät mahdu kokorajoihinsa.",
|
||||
"ui.panel_host.rule.capability": "Tämä isäntä ei tue kyseistä toimintoa.",
|
||||
"ui.panel_host.rule.collection_limit": "Paneeliasettelu on saavuttanut rajansa.",
|
||||
"ui.panel_host.rule.duplicate_id": "Tämä paneelin tunniste on jo käytössä.",
|
||||
"ui.panel_host.rule.exactly_once": "Jokaisen paneelin on oltava mukana kerran.",
|
||||
"ui.panel_host.rule.feature_disabled": "Tämä toiminto on poistettu käytöstä tässä paneelissa.",
|
||||
"ui.panel_host.rule.id": "Paneelin tunniste on virheellinen.",
|
||||
"ui.panel_host.rule.incompatible_tabs": "Nämä paneelit eivät voi jakaa välilehtiryhmää.",
|
||||
"ui.panel_host.rule.lifecycle": "Paneeli ei voinut suorittaa tätä toimintoa loppuun.",
|
||||
"ui.panel_host.rule.missing_target": "Tämä paneelin kohde ei ole enää käytettävissä.",
|
||||
"ui.panel_host.rule.no_space": "Tälle paneeliasettelulle ei ole tilaa.",
|
||||
"ui.panel_host.rule.pin_conflict": "Toinen paneeli on jo kiinnitetty tähän reunaan.",
|
||||
"ui.panel_host.rule.pin_order": "Kiinnitetyn paneelin on pysyttävä reunansa ensimmäisenä.",
|
||||
"ui.panel_host.rule.placement": "Tätä paneelia ei voi siirtää kyseiseen paikkaan.",
|
||||
"ui.panel_host.rule.revision": "Asettelu muuttui. Yritä toimintoa uudelleen.",
|
||||
"ui.panel_host.rule.schema": "Paneelipyyntö on virheellinen.",
|
||||
"ui.panel_host.rule.version": "Tätä paneeliasettelun versiota ei tueta.",
|
||||
"ui.panel_host.sash": "Muuta viereisten paneelien kokoa",
|
||||
"ui.panel_host.scroll_horizontal": "Vieritä paneelisarakkeita",
|
||||
"ui.panel_host.scroll_vertical": "Vieritä paneelipinoja",
|
||||
"ui.panel_host.surface": "Paneeli",
|
||||
"ui.panel_host.tab": "Paneelin välilehti",
|
||||
"ui.panel_host.tabs": "Kaikki välilehdet",
|
||||
"ui.panel_layout.applied": "Asettelu {name} otettu käyttöön työtilassa {workspace}",
|
||||
"ui.panel_layout.cancel": "Peruuta",
|
||||
"ui.panel_layout.confirm_replace": "Korvataanko ”{name}”?",
|
||||
"ui.panel_layout.copy": "Kopioi",
|
||||
"ui.panel_layout.current": "Nykyinen asettelu",
|
||||
"ui.panel_layout.custom": "Mukautettu",
|
||||
"ui.panel_layout.custom_hint": "Paneeleja on siirretty asettelun {name} jälkeen.",
|
||||
"ui.panel_layout.details": "Tiedot",
|
||||
"ui.panel_layout.layout_workspace": "Asettelun työtila",
|
||||
"ui.panel_layout.page_defaults_hint": "Palauttaa tämän sivun asetukset. Paneelien järjestys ja tallennetut asettelut säilyvät.",
|
||||
"ui.panel_layout.panel_layout": "Paneeliasettelu",
|
||||
"ui.panel_layout.panel_style": "Paneelityyli",
|
||||
"ui.panel_layout.replace": "Korvaa asettelu",
|
||||
"ui.panel_layout.replace_current": "Korvaa nykyisellä",
|
||||
"ui.panel_layout.replace_short": "Korvaa",
|
||||
"ui.panel_layout.reset": "Palauta työtila",
|
||||
"ui.panel_layout.reset_hint": "Palauta työtilan {workspace} oletusjärjestys. Tallennetut asettelut säilyvät.",
|
||||
"ui.panel_layout.reset_short": "Palauta",
|
||||
"ui.panel_layout.restore_failed": "Asettelua ei voitu palauttaa",
|
||||
"ui.panel_layout.save": "Tallenna asettelu",
|
||||
"ui.panel_layout.save_as": "Tallenna nimellä",
|
||||
"ui.panel_layout.save_as_tile": "Tallenna nimellä…",
|
||||
"ui.panel_layout.selected": "valittu",
|
||||
"ui.panel_layout.suggested_name": "Asettelu {n}",
|
||||
"ui.panel_layout.try_again": "Yritä uudelleen",
|
||||
"ui.panel_layout.validation.builtin": "Sisäänrakennettuja asetteluja ei voi muuttaa",
|
||||
"ui.panel_layout.validation.duplicate": "Tämä nimi on jo käytössä",
|
||||
"ui.panel_layout.validation.empty": "Anna nimi",
|
||||
"ui.panel_layout.validation.full": "64/64 tallennettu. Valitse korvattava asettelu.",
|
||||
"ui.panel_layout.validation.invalid": "Käytä 1–64 merkkiä, ei ohjausmerkkejä",
|
||||
"ui.panel_layout.workspace.classic": "Klassinen",
|
||||
"ui.panel_layout.workspace.modelling": "Mallinnus",
|
||||
"ui.panel_layout.workspace.render": "Renderöinti",
|
||||
"ui.panel_layout.workspace.review": "Tarkastelu",
|
||||
"ui.panel_layout.workspace.sketch": "Luonnos",
|
||||
"ui.panel_layout.your_layout": "oma asettelusi",
|
||||
"ui.point_cloud_dots": "Pisteet",
|
||||
"ui.point_cloud_reload": "Lataa uudelleen tiedostosta",
|
||||
"ui.project_references_construction": "Projisoi viitteet apugeometriaksi",
|
||||
"ui.project_references_construction_note": "Projisoi, Leikkaa, Sisällytä 3D-geometria ja pinnalle luodun uuden luonnoksen pinnan särmät tuottavat apugeometriaa: viitteitä, joita vasten piirretään ja jotka jätetään profiilien ulkopuolelle. Ne pysyvät linkitettyinä malliin.",
|
||||
"ui.quick_access_full": "Pikakäyttöpalkkiin mahtuu {count} komentoa; poista yksi lisätäksesi toisen",
|
||||
"ui.removeFromQuickAccess": "Poista pikakäytöstä",
|
||||
"ui.replace_sketch.from_file": "Mallitiedostosta…",
|
||||
"ui.replace_sketch.no_sketches": "Mallissa ei ole luonnosta, jonka voisi ottaa",
|
||||
"ui.report_characters_left_one": "{count} merkki jäljellä",
|
||||
"ui.report_characters_left_other": "{count} merkkiä jäljellä",
|
||||
"ui.segments": "Segmentit",
|
||||
"view_gizmo.arrows": "Kiertonuolet",
|
||||
"view_gizmo.home": "Aloitusnäkymäpainike",
|
||||
"view_gizmo.no_such_body": "Tässä mallissa ei ole sen nimistä kappaletta.",
|
||||
"view_gizmo.note": "Valitse kappale näkymästä. Se tallennetaan asetuksiisi ja asetusprofiileihisi, ja se näytetään jokaisessa mallissa. Napsauta jotakin sen pinnoista katsoaksesi sitä suoraan.",
|
||||
"view_gizmo.texture": "Tekstuuri",
|
||||
"view_gizmo.themed": "Teeman mukainen",
|
||||
"view_gizmo.too_detailed": "Liian yksityiskohtainen näkymänavigaattoriin (yli 10 000 kolmiota)",
|
||||
"view_gizmo.too_thin": "Liian pitkä ja ohut näkymänavigaattoriin: sen ohuimman sivun on oltava vähintään viidesosa pisimmästä"
|
||||
}
|
||||
}
|
||||
@@ -149,8 +149,14 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
// A mesh body is one face with no edges: the flat patch of triangles picked,
|
||||
// traced (#13464).
|
||||
if target.body.is_mesh() {
|
||||
crate::project3d::flat_outline(&mesh, q).iter().flat_map(|outline| fit_mesh_outline(plane, outline)).collect()
|
||||
} else {
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,8 +1314,8 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
}
|
||||
|
||||
/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether
|
||||
/// the polyline closes.
|
||||
fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
/// the polyline closes. Its points lie on it within `within`, by default 1e-7 of the radius.
|
||||
fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return None;
|
||||
@@ -1326,15 +1332,15 @@ fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2);
|
||||
let r = centre.dist(a);
|
||||
let un = nrm.normalized()?;
|
||||
let tol = 1e-7 * r.max(1.0);
|
||||
let tol = within.unwrap_or(1e-7 * r.max(1.0));
|
||||
pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed))
|
||||
}
|
||||
|
||||
/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc
|
||||
/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection
|
||||
/// keeps their weights).
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3], within: Option<f64>) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts, within)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
@@ -1429,9 +1435,31 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
|
||||
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
|
||||
}
|
||||
|
||||
/// Most corners a mesh face's outline has and is still taken for a polygon.
|
||||
const MESH_POLYGON_CORNERS: usize = 12;
|
||||
|
||||
/// The outline of a mesh body's flat patch (a closed loop of its corners) as lines and arcs. Any
|
||||
/// triangle's corners and any rectangle's lie on one circle, so only a loop of more corners than
|
||||
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
|
||||
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
|
||||
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
|
||||
/// size, not of the circle's.
|
||||
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
|
||||
if pts.len() > MESH_POLYGON_CORNERS + 1
|
||||
&& let Some(e) = tilted_circle(plane, pts, Some(within))
|
||||
{
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let mut out = Vec::new();
|
||||
fit_chain(&p2, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Project a world polyline and fit lines and arcs to it.
|
||||
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
if let Some(e) = tilted_circle(plane, pts) {
|
||||
if let Some(e) = tilted_circle(plane, pts, None) {
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
|
||||
@@ -278,6 +278,134 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
|
||||
Some(e2.dot(q) * inv)
|
||||
}
|
||||
|
||||
/// Most triangles [`flat_outline`] looks through.
|
||||
const FLAT_OUTLINE_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The outline of the flat patch of a mesh body's triangles at `at`: the triangle nearest `at`
|
||||
/// and every triangle joined to it edge to edge on its plane, traced as closed loops, each with its
|
||||
/// first point again at its end. An imported mesh is one face, so this flat patch is what a pick
|
||||
/// on one of its faces means (#13464). Empty when the mesh has no triangle or is too large.
|
||||
pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
if m.triangles.is_empty() || m.triangles.len() > FLAT_OUTLINE_TRIANGLES {
|
||||
return Vec::new();
|
||||
}
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
// Corners at one place are one corner: an STL repeats each corner for every triangle.
|
||||
let weld = size * 1e-9;
|
||||
let mut ids: HashMap<(i64, i64, i64), usize> = HashMap::new();
|
||||
let mut corners: Vec<Vec3> = Vec::new();
|
||||
let mut tri: Vec<[usize; 3]> = Vec::with_capacity(m.triangles.len());
|
||||
for t in &m.triangles {
|
||||
let Some(ps) = m.tri(t) else { continue };
|
||||
let mut out = [0; 3];
|
||||
for (o, p) in out.iter_mut().zip(ps) {
|
||||
if !p.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
let key = ((p.x / weld).round() as i64, (p.y / weld).round() as i64, (p.z / weld).round() as i64);
|
||||
*o = *ids.entry(key).or_insert_with(|| {
|
||||
corners.push(p);
|
||||
corners.len() - 1
|
||||
});
|
||||
}
|
||||
tri.push(out);
|
||||
}
|
||||
let at_of = |t: &[usize; 3]| t.map(|i| corners.get(i).copied().unwrap_or_default());
|
||||
let normal = |t: &[usize; 3]| {
|
||||
let [a, b, c] = at_of(t);
|
||||
(b - a).cross(c - a).normalized()
|
||||
};
|
||||
// The triangle nearest the pick, and its plane.
|
||||
let Some(seed) = (0..tri.len()).filter(|i| tri.get(*i).and_then(normal).is_some()).min_by(|x, y| {
|
||||
let d = |i: &usize| {
|
||||
tri.get(*i).map_or(f64::INFINITY, |t| {
|
||||
let [a, b, c] = at_of(t);
|
||||
crate::project::closest_on_triangle(at, a, b, c).dist(at)
|
||||
})
|
||||
};
|
||||
d(x).total_cmp(&d(y))
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((n0, p0)) = tri.get(seed).and_then(|t| Some((normal(t)?, at_of(t)[0]))) else { return Vec::new() };
|
||||
let flat = |t: &[usize; 3]| normal(t).is_some_and(|n| n.dot(n0) > 1.0 - 1e-6) && at_of(t).iter().all(|p| (*p - p0).dot(n0).abs() <= size * 1e-6);
|
||||
// Triangles by the edges they share.
|
||||
let mut by_edge: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
for (i, t) in tri.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
by_edge.entry((a.min(b), a.max(b))).or_default().push(i);
|
||||
}
|
||||
}
|
||||
// The flat patch: from the seed across shared edges onto triangles on its plane.
|
||||
let mut patch: HashSet<usize> = HashSet::from([seed]);
|
||||
let mut queue = VecDeque::from([seed]);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let Some(t) = tri.get(i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
for j in by_edge.get(&(a.min(b), a.max(b))).into_iter().flatten() {
|
||||
if !patch.contains(j) && tri.get(*j).is_some_and(flat) {
|
||||
patch.insert(*j);
|
||||
queue.push_back(*j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Its outline: the edges only one of its triangles has, each the way its triangle runs.
|
||||
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in &patch {
|
||||
let Some(t) = tri.get(*i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
let inside = by_edge.get(&(a.min(b), a.max(b))).map_or(0, |ts| ts.iter().filter(|j| patch.contains(j)).count());
|
||||
if inside == 1 {
|
||||
next.entry(a).or_default().push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut loops = Vec::new();
|
||||
let mut starts: Vec<usize> = next.keys().copied().collect();
|
||||
starts.sort_unstable();
|
||||
for start in starts {
|
||||
while let Some(mut v) = next.get_mut(&start).and_then(Vec::pop) {
|
||||
let mut pts: Vec<Vec3> = [start, v].iter().filter_map(|i| corners.get(*i).copied()).collect();
|
||||
let mut steps = 0;
|
||||
while v != start && steps < patch.len() * 3 {
|
||||
let Some(w) = next.get_mut(&v).and_then(Vec::pop) else { break };
|
||||
pts.extend(corners.get(w).copied());
|
||||
v = w;
|
||||
steps += 1;
|
||||
}
|
||||
if v == start && pts.len() >= 4 {
|
||||
loops.extend(turning_corners(&pts, size * 1e-9));
|
||||
}
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
|
||||
/// A closed loop (its first point again at its end) without the corners where it runs straight
|
||||
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
|
||||
/// meets it. None when fewer than three corners turn.
|
||||
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
|
||||
let ring = pts.get(..pts.len().saturating_sub(1))?;
|
||||
let n = ring.len();
|
||||
let turns = |i: usize| {
|
||||
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
|
||||
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
|
||||
let (a, b) = (*here - *prev, *next - *here);
|
||||
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
|
||||
};
|
||||
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
|
||||
if out.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
out.extend(out.first().copied());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
|
||||
/// miss the face split the result.
|
||||
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
|
||||
|
||||
@@ -12,6 +12,7 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
||||
use crate::schema::features::SweepParams;
|
||||
use crate::schema::features::{
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
@@ -27,6 +28,7 @@ const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
|
||||
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
|
||||
const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
|
||||
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
|
||||
const SWEEP_CONTRACT: TypedContract<SweepParams> = TypedContract::new();
|
||||
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
|
||||
@@ -47,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.contract(&SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
|
||||
@@ -3561,17 +3565,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(Dir(d))
|
||||
}
|
||||
Some(v) => sweep_orientation(v).map_err(|e| bad(cmd, e))?,
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -3599,6 +3593,24 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
/// A sweep's `orientation` as given (not `null`): perpendicular or parallel (any case), or a
|
||||
/// non-zero direction `[x, y, z]`, `{x, y, z}` or `{direction: …}`. The error is the message.
|
||||
pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientation, &'static str> {
|
||||
match v {
|
||||
Value::String(o) if o.eq_ignore_ascii_case("perpendicular") => Ok(SweepOrientation::Perpendicular),
|
||||
Value::String(o) if o.eq_ignore_ascii_case("parallel") => Ok(SweepOrientation::Parallel),
|
||||
v => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or("`orientation` must be perpendicular, parallel or a direction [x, y, z]")?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err("the `orientation` direction must not be zero");
|
||||
}
|
||||
Ok(SweepOrientation::Direction(Dir(d)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
|
||||
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
|
||||
/// stretch; and no orientation, twist or scale besides, since the rails set those.
|
||||
@@ -3619,9 +3631,7 @@ pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -
|
||||
pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
|
||||
let rail_fit = match p.get("rail_fit") {
|
||||
None | Some(Value::Null) => RailFit::Scale,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
|
||||
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
|
||||
Some(v) => rail_fit_form(v).map_err(|e| bad(cmd, e))?,
|
||||
};
|
||||
let given = match p.get("rails") {
|
||||
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
|
||||
@@ -3647,6 +3657,16 @@ pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default
|
||||
Ok((rails, rail_fit))
|
||||
}
|
||||
|
||||
/// Guide rails' `rail_fit` as given (not `null`): scale or stretch, in any case. The error is
|
||||
/// the message.
|
||||
pub(crate) fn rail_fit_form(v: &Value) -> std::result::Result<RailFit, &'static str> {
|
||||
match v {
|
||||
Value::String(f) if f.eq_ignore_ascii_case("scale") => Ok(RailFit::Scale),
|
||||
Value::String(f) if f.eq_ignore_ascii_case("stretch") => Ok(RailFit::Stretch),
|
||||
_ => Err("`rail_fit` must be scale or stretch"),
|
||||
}
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub(crate) use features::{rail_fit_form, sweep_orientation};
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
|
||||
@@ -428,8 +428,10 @@ fn add_link_unless_present(
|
||||
if skip_present && r.wires.is_empty() && sk.link_geometry_present(&r.geom) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A mesh body is one face whose name says nothing of the flat patch picked: the link keeps
|
||||
// the point instead (#13464), as an edge of a mesh body keeps no name.
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
st.body(body).filter(|body| !body.body.is_mesh()).and_then(|body| {
|
||||
if let Some(index) = r.face_index {
|
||||
solidcraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
} else {
|
||||
|
||||
@@ -1803,3 +1803,87 @@ fn projected_references_can_start_as_construction() {
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
|
||||
assert_eq!(flags(&mut s), vec![true, false]);
|
||||
}
|
||||
|
||||
/// #13464: an imported mesh is one face with no edges, so Project on one of its faces projected
|
||||
/// nothing. The pick now means the flat patch of triangles there: a box's top projects as its
|
||||
/// rectangle, its side seen edge-on as a line, and the link keeps the pick's point (a mesh's face
|
||||
/// name says nothing of the patch), so the sketch still builds once finished.
|
||||
#[test]
|
||||
fn a_face_of_an_imported_mesh_projects_its_flat_patch() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let stl = dir.join("box.stl");
|
||||
let mut src = Session::default();
|
||||
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
|
||||
run(&mut src, "file.export_stl", json!({"path": stl.to_string_lossy()}));
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 5, 5]}]}));
|
||||
let si = inspect(&mut s);
|
||||
let lines = linked_curves(&si);
|
||||
assert_eq!(lines.len(), 4, "{si}");
|
||||
let mut corners: Vec<(i64, i64)> = lines
|
||||
.iter()
|
||||
.flat_map(|c| [&c["start_at"], &c["end_at"]])
|
||||
.map(|p| ((p[0].as_f64().unwrap() * 1e6).round() as i64, (p[1].as_f64().unwrap() * 1e6).round() as i64))
|
||||
.collect();
|
||||
corners.sort_unstable();
|
||||
corners.dedup();
|
||||
assert_eq!(corners, [(0, 0), (0, 10_000_000), (20_000_000, 0), (20_000_000, 10_000_000)], "{si}");
|
||||
assert!(s.active_sketch.is_some());
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let failing: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||
assert!(failing.is_empty(), "{failing:?}");
|
||||
|
||||
// A side seen edge-on: the line it makes.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 0, 2.5]}]}));
|
||||
assert_eq!(linked_curves(&inspect(&mut s)).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// #13464: on a sketch plane at a slant to the face, a flat patch's polygon keeps its straight
|
||||
/// sides. A rectangle's four corners and a triangle's three always lie on one circle, so they must
|
||||
/// not be taken for a circle seen at a slant (an ellipse); a tessellated circle still is one.
|
||||
#[test]
|
||||
fn a_mesh_face_keeps_its_straight_sides_on_a_slanted_plane() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-slant-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut src = Session::default();
|
||||
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5, "body_name": "box"}));
|
||||
run(&mut src, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut src, "sketch.line", json!({"points": [[30, 0], [42, 0], [34, 9], [30, 0]]}));
|
||||
run(&mut src, "sketch.finish", json!({}));
|
||||
let sketch = src.doc.features.last().unwrap().id;
|
||||
run(&mut src, "solid.extrude", json!({"sketch": sketch, "distance": 5, "body_name": "tri"}));
|
||||
run(&mut src, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 7, "height": 5}));
|
||||
let mut stls = Vec::new();
|
||||
for body in src.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>() {
|
||||
let path = dir.join(format!("{body}.stl"));
|
||||
run(&mut src, "file.export_stl", json!({"path": path.to_string_lossy(), "bodies": [body]}));
|
||||
stls.push(path);
|
||||
}
|
||||
|
||||
let mut s = Session::default();
|
||||
for stl in &stls {
|
||||
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
|
||||
}
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": "30 deg", "name": "Tilt"}));
|
||||
let kinds = |s: &mut Session, body: &str, at: [f64; 3]| -> Vec<String> {
|
||||
run(s, "sketch.create", json!({"plane": "Tilt"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"body": body, "face": at}]}));
|
||||
let kinds = linked_curves(&inspect(s)).iter().map(|c| c["type"].as_str().unwrap_or("").to_string()).collect();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
kinds
|
||||
};
|
||||
assert_eq!(kinds(&mut s, &names[0], [10., 5., 5.]), ["line"; 4], "the box's top is a rectangle");
|
||||
assert_eq!(kinds(&mut s, &names[1], [35., 3., 5.]), ["line"; 3], "the prism's top is a triangle");
|
||||
let round = kinds(&mut s, &names[2], [60., 0., 5.]);
|
||||
assert_eq!(round.len(), 1, "the cylinder's top is one curve: {round:?}");
|
||||
assert_eq!(round[0], "ellipse", "the cylinder's round top, seen at a slant: {round:?}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use solidcraft_kernel::{Construction, Continuity};
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
|
||||
use crate::schema::surface::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
@@ -26,6 +26,7 @@ const THICKEN_CONTRACT: TypedContract<ThickenParams> = TypedContract::new();
|
||||
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
|
||||
const UNTRIM_CONTRACT: TypedContract<UntrimParams> = TypedContract::new();
|
||||
const FAIR_CONTRACT: TypedContract<FairParams> = TypedContract::new();
|
||||
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
@@ -60,9 +61,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", crate::checked!("surface.sweep", surface_sweep))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.contract(&SURFACE_SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
|
||||
.at("SURFACE", "CREATE")
|
||||
|
||||
@@ -361,6 +361,110 @@ pub struct PipeParams {
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Sweep sketch profiles along a path of sketch curves into a solid.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SweepParams {
|
||||
/// The sketch holding the profiles, by id or name. Default: the active sketch, else the last
|
||||
/// one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IdOrName")]
|
||||
pub sketch: Option<IdOrName>,
|
||||
/// Which of the sketch's profiles to sweep. Default: all of them.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
|
||||
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path.
|
||||
pub path: StringList,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
/// How the sweep combines with existing bodies. Default: new.
|
||||
#[serde(default)]
|
||||
pub operation: Option<OperationParam>,
|
||||
/// The bodies a join, cut or intersect works on; given, they win over `participants`.
|
||||
/// Default: `participants`, else for a join from a sketch on a body's face that body, else
|
||||
/// every body in the active component (an intersect removes those the sweep doesn't meet),
|
||||
/// or for a cut or intersect where it has none, every body.
|
||||
#[serde(default)]
|
||||
pub targets: Option<StringList>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Sweep sketch curves or profile boundaries along a path of sketch curves into a surface body:
|
||||
/// walls only, no caps. A path with corners, or of 3D curves, gives ruled walls mitred at the
|
||||
/// corners. `participants` changes nothing here.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SurfaceSweepParams {
|
||||
/// The sketch holding the curves or profiles, by id or name. Default: the active sketch, else
|
||||
/// the last one.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IdOrName")]
|
||||
pub sketch: Option<IdOrName>,
|
||||
/// Curve ids to sweep, at most 1000: open or closed chains of curves meeting end to end.
|
||||
#[serde(default)]
|
||||
pub curves: Option<StringList>,
|
||||
/// Which of the sketch's profiles to sweep the boundaries of. Default: all of them when no
|
||||
/// `curves` are given, else none.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProfilesParam")]
|
||||
pub profiles: Option<ProfilesParam>,
|
||||
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
|
||||
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path: 1 to 1000.
|
||||
pub path: StringList,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// How a sweep's profile turns and changes size along the path, as `features::sweep_options`
|
||||
/// and `features::sweep_rails` read it (solid.sweep and surface.sweep), flattened. Guide rails
|
||||
/// set the turn and the size themselves: with `rails`, the handler refuses `orientation`,
|
||||
/// `twist` and `scale`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SweepShape {
|
||||
/// How the profile turns as it follows the path. Default: perpendicular.
|
||||
#[serde(default)]
|
||||
pub orientation: Option<super::types::SweepOrientationParam>,
|
||||
/// How far the profile turns about the path over its length, from none at the start: within
|
||||
/// four turns (1440 deg) either way. Default: none.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "Option<AngleExpr>")]
|
||||
pub twist: Option<super::types::SweepTwist>,
|
||||
/// The profile's scale at the end of the path, changing linearly along it from 1 at the
|
||||
/// start: 0.01 to 100. Default: 1.
|
||||
#[serde(default)]
|
||||
pub scale: Option<NumberExpr>,
|
||||
/// Guide rails, each meeting every plane square to the path once along it, followed from the
|
||||
/// end nearest the path's start. One rail turns the profile so that its point where the rail
|
||||
/// starts stays on the rail, and scales it with the rail's distance from the path; two rails
|
||||
/// carry it between them, its points where they start staying on both. The path must have no
|
||||
/// sharp corners.
|
||||
#[serde(default)]
|
||||
pub rails: Option<super::types::SweepRails>,
|
||||
/// How the rails reshape the profile. Default: scale. Used only with `rails`.
|
||||
#[serde(default)]
|
||||
pub rail_fit: Option<super::types::RailFitParam>,
|
||||
}
|
||||
|
||||
/// Add a box around bodies, larger by a margin on every side.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
|
||||
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use super::{SurfaceSweepParams, SweepParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -309,6 +310,321 @@ fn combine_replies_with_combine_result() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_schemas_and_the_key_check_agree() {
|
||||
let shape = ["orientation", "rail_fit", "rails", "scale", "twist"];
|
||||
let mut solid = vec![
|
||||
"body_name",
|
||||
"body_names",
|
||||
"exact_regions",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"path",
|
||||
"path_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
"targets",
|
||||
"touching",
|
||||
];
|
||||
solid.extend(shape);
|
||||
solid.sort_unstable();
|
||||
assert_keys(input_schema_for::<SweepParams>(), &solid);
|
||||
let mut surface = vec!["body_name", "body_names", "curves", "exact_regions", "name", "participants", "path", "path_sketch", "profiles", "sketch"];
|
||||
surface.extend(shape);
|
||||
surface.sort_unstable();
|
||||
assert_keys(input_schema_for::<SurfaceSweepParams>(), &surface);
|
||||
}
|
||||
|
||||
/// The calls both sweeps refuse today, as `sweep` and `surface_sweep` read them, each run on
|
||||
/// `sketch_rectangle`'s sketch (`Sketch1`, which is the path sketch too: the path's curves are
|
||||
/// only looked up when the feature is evaluated).
|
||||
#[test]
|
||||
fn sweep_strict_keeps_todays_errors() {
|
||||
let rail = json!([{"curves": ["l1"]}]);
|
||||
let mut cases = Vec::new();
|
||||
for extra in [
|
||||
json!({"orientation": "sideways"}),
|
||||
json!({"orientation": " parallel"}),
|
||||
json!({"orientation": 5}),
|
||||
json!({"orientation": [0, 0, 0]}),
|
||||
json!({"orientation": [1, 0]}),
|
||||
json!({"orientation": {"direction": [0, 0, 0]}}),
|
||||
json!({"orientation": {"direction": "x"}}),
|
||||
json!({"orientation": {"x": 1, "y": 0}}),
|
||||
json!({"twist": true}),
|
||||
json!({"twist": " "}),
|
||||
json!({"twist": [10]}),
|
||||
json!({"twist": 1e9}),
|
||||
json!({"twist": "no_such_parameter"}),
|
||||
json!({"twist": "1441 deg"}),
|
||||
json!({"scale": true}),
|
||||
json!({"scale": 1e9}),
|
||||
json!({"scale": "x".repeat(4097)}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": "no_such_parameter"}),
|
||||
json!({"rail_fit": "wobble"}),
|
||||
json!({"rail_fit": 1}),
|
||||
json!({"rail_fit": "stretch "}),
|
||||
json!({"rails": "l1"}),
|
||||
json!({"rails": {"curves": ["l1"]}}),
|
||||
json!({"rails": []}),
|
||||
json!({"rails": [{"curves": ["l1"]}, {"curves": ["l2"]}, {"curves": ["l3"]}]}),
|
||||
json!({"rails": [{}]}),
|
||||
json!({"rails": [5]}),
|
||||
json!({"rails": [{"curves": []}]}),
|
||||
json!({"rails": [{"curves": null}]}),
|
||||
json!({"rails": [{"curves": 5}]}),
|
||||
json!({"rails": [{"curves": ["l1", 2]}]}),
|
||||
json!({"rails": [{"curves": vec!["l1"; 1001]}]}),
|
||||
json!({"rails": [{"curves": vec!["l1"; 10_001]}]}),
|
||||
json!({"rails": [{"sketch": true, "curves": ["l1"]}]}),
|
||||
json!({"rails": [{"sketch": [], "curves": ["l1"]}]}),
|
||||
json!({"rails": [{"curves": ["l1"]}, {"sketch": {}, "curves": ["l2"]}]}),
|
||||
json!({"rails": [{"sketch": "Missing", "curves": ["l1"]}]}),
|
||||
json!({"rails": rail, "orientation": "parallel"}),
|
||||
json!({"rails": rail, "twist": "10 deg"}),
|
||||
json!({"rails": rail, "scale": 2}),
|
||||
json!({"path_sketch": true}),
|
||||
json!({"path_sketch": null}),
|
||||
json!({"path_sketch": "Missing"}),
|
||||
json!({"path": []}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null}),
|
||||
json!({"sketch": "Missing"}),
|
||||
json!({"profiles": []}),
|
||||
json!({"profiles": [true]}),
|
||||
json!({"profiles": [{"point": [1]}]}),
|
||||
json!({"profiles": null}),
|
||||
json!({"participants": ["Missing"]}),
|
||||
] {
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
let mut p = json!({"path_sketch": "Sketch1", "path": ["l1"]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
cases.push((id, p));
|
||||
}
|
||||
}
|
||||
cases.extend([
|
||||
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "bogus"})),
|
||||
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "auto"})),
|
||||
("surface.sweep", json!({"path_sketch": "Sketch1", "path": vec!["l1"; 1001]})),
|
||||
("surface.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "curves": vec!["l1"; 1001]})),
|
||||
]);
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// A circle `C` (radius 10, curve `c`) and a line `L` across it (`l`) on XY; a path `P` on XZ, a
|
||||
/// line 40 up the Z axis (`up`), with a rail beside it (`in`) from the circle's edge in to x = 5
|
||||
/// at the top; a block `Block` the sweep cuts a quarter from, and a plate `Plate` inside it.
|
||||
fn sweep_rig(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY", "name": "C"})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": "XY", "name": "L"})),
|
||||
("sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": "XZ", "name": "P"})),
|
||||
// The XZ sketch's y runs down world Z.
|
||||
("sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]})),
|
||||
("sketch.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 10, "body_name": "Block"})),
|
||||
("solid.box", json!({"corner": [-3, -3, 20], "length": 6, "width": 6, "height": 5, "body_name": "Plate"})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Every form a sweep call takes, each command with its own.
|
||||
fn sweep_calls() -> Vec<(&'static str, Value)> {
|
||||
let on = |p: Value| {
|
||||
let mut call = json!({"path_sketch": "P", "path": ["up"]});
|
||||
call.as_object_mut().into_iter().for_each(|o| o.extend(p.as_object().cloned().unwrap_or_default()));
|
||||
call
|
||||
};
|
||||
let solid = [
|
||||
json!({"sketch": "C", "name": "A", "body_name": "A"}),
|
||||
json!({"sketch": "C", "profiles": "all", "exact_regions": true, "path": "up", "orientation": "Parallel", "body_names": ["B"]}),
|
||||
json!({"sketch": "C", "profiles": [0], "orientation": [1, 0, 0], "twist": "90 deg", "scale": 0.5, "operation": "new"}),
|
||||
json!({"sketch": "C", "orientation": {"direction": {"x": 1, "y": 0, "z": 0}}, "twist": 45, "scale": "1.5"}),
|
||||
json!({"sketch": "C", "orientation": "perpendicular", "twist": null, "scale": null, "rails": null, "rail_fit": null, "touching": null}),
|
||||
json!({"sketch": "C", "rails": [{"curves": ["in"]}]}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": "in"}], "rail_fit": "Stretch"}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": null, "curves": ["in"]}], "rail_fit": "scale"}),
|
||||
json!({"sketch": "C", "operation": "join", "targets": ["A"], "touching": "one_body", "participants": []}),
|
||||
json!({"sketch": "C", "operation": "cut", "targets": "Block", "name": "Cut"}),
|
||||
json!({"sketch": "C", "operation": "intersect", "participants": ["Plate"]}),
|
||||
];
|
||||
let surface = [
|
||||
json!({"sketch": "L", "curves": ["l"], "body_name": "W"}),
|
||||
json!({"sketch": "L", "curves": "l", "orientation": [1, 0, 0], "twist": "30 deg", "scale": 2, "body_names": ["X"]}),
|
||||
json!({"sketch": "C", "profiles": [0], "exact_regions": false, "orientation": "PARALLEL"}),
|
||||
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": ["in"]}], "rail_fit": "stretch", "name": "Railed"}),
|
||||
json!({"sketch": "C", "profiles": "all", "orientation": null, "rails": null, "participants": []}),
|
||||
];
|
||||
let mut calls: Vec<(&'static str, Value)> = solid.into_iter().map(|p| ("solid.sweep", on(p))).collect();
|
||||
calls.extend(surface.into_iter().map(|p| ("surface.sweep", on(p))));
|
||||
calls
|
||||
}
|
||||
|
||||
/// `solid.sweep` and `surface.sweep` reply with `FeatureResult` in each of their forms, their
|
||||
/// contracts taking each: in Warn a contract that refused one would still run it, so the replies
|
||||
/// are checked for warnings.
|
||||
#[test]
|
||||
fn sweep_replies_match_the_output_schema_and_forms_are_accepted() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = sweep_rig(ParamCheck::Warn);
|
||||
for (id, p) in sweep_calls() {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
}
|
||||
// Warn reports what the contracts refuse and runs the call anyway.
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
let typo = s.execute(id, &json!({"sketch": "C", "path_sketch": "P", "path": ["up"], "twistt": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(typo["param_warnings"], json!(["unknown key `twistt`; did you mean `twist`?"]), "{id}");
|
||||
assert!(conforms(schema, schema, &typo), "{typo}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The check changes nothing about the replies: each call gives the same JSON with the check off
|
||||
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the commands always
|
||||
/// answered with.
|
||||
#[test]
|
||||
fn sweep_replies_keep_their_old_shape_whatever_the_check() {
|
||||
let (mut off, mut strict) = (sweep_rig(ParamCheck::Off), sweep_rig(ParamCheck::Strict));
|
||||
for (id, p) in sweep_calls() {
|
||||
let run = |s: &mut Session| {
|
||||
let mut r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
|
||||
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
|
||||
r["ms"] = Value::Null;
|
||||
(r, keys)
|
||||
};
|
||||
let (a, a_keys) = run(&mut off);
|
||||
let (b, _) = run(&mut strict);
|
||||
assert_eq!(a, b, "{id} {p}");
|
||||
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
|
||||
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
|
||||
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the handlers ignore or read as left out, strict refuses; off and warn run it as before.
|
||||
#[test]
|
||||
fn sweep_wrong_typed_keys_are_reported() {
|
||||
let mut cases: Vec<(&str, Value, Value)> = Vec::new();
|
||||
for id in ["solid.sweep", "surface.sweep"] {
|
||||
for (key, value) in
|
||||
[("exact_regions", json!(1)), ("name", json!(5)), ("body_name", json!(5)), ("colour", json!("red")), ("orient", json!("parallel"))]
|
||||
{
|
||||
cases.push((id, json!({ key: value }), json!(1)));
|
||||
}
|
||||
// A misspelt key inside a rail: the handler reads the rail as in the path's sketch.
|
||||
cases.push((id, json!({"rails": [{"curves": ["in"], "skecth": "P"}]}), json!(1)));
|
||||
}
|
||||
cases.push(("solid.sweep", json!({"operation": 5}), json!(1)));
|
||||
// The surface sweep doesn't combine bodies: its handler leaves these alone.
|
||||
cases.push(("surface.sweep", json!({"operation": "join", "targets": ["Block"]}), json!(2)));
|
||||
cases.push(("surface.sweep", json!({"touching": "one_body"}), json!(1)));
|
||||
for (id, extra, warnings) in cases {
|
||||
let mut p = json!({"sketch": "C", "path_sketch": "P", "path": ["up"]});
|
||||
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
|
||||
let mut off = sweep_rig(ParamCheck::Off);
|
||||
let r = off.execute(id, &p);
|
||||
assert!(r.is_ok(), "{id} {p}: {r:?}");
|
||||
let mut warn = sweep_rig(ParamCheck::Warn);
|
||||
let r = warn.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(r["param_warnings"].as_array().map(Vec::len), warnings.as_u64().map(|n| n as usize), "{id} {p}: {r}");
|
||||
let mut strict = sweep_rig(ParamCheck::Strict);
|
||||
assert!(strict.execute(id, &p).is_err(), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweep value types take what the handlers' readers take, and their schemas say the same,
|
||||
/// apart from what a schema can't say (case, a zero direction, the rail curves' 10 000 cap).
|
||||
#[test]
|
||||
fn sweep_values_match_the_handler_readers() {
|
||||
use crate::schema::types::{RailFitParam, SweepOrientationParam, SweepRails, SweepTwist};
|
||||
fn accepts<T: serde::de::DeserializeOwned>(v: &Value) -> bool {
|
||||
serde_json::from_value::<T>(v.clone()).is_ok()
|
||||
}
|
||||
let orientation = input_schema_for::<SweepOrientationParam>();
|
||||
for (v, accepted, schema) in [
|
||||
(json!("perpendicular"), true, true),
|
||||
(json!("parallel"), true, true),
|
||||
(json!("Parallel"), true, false),
|
||||
(json!(" parallel"), false, false),
|
||||
(json!("sideways"), false, false),
|
||||
(json!([0, 0, 1]), true, true),
|
||||
(json!({"x": 0, "y": 1, "z": 0}), true, true),
|
||||
(json!({"direction": [1, 0, 0]}), true, true),
|
||||
(json!({"direction": {"x": 1, "y": 0, "z": 0}, "extra": 1}), true, true),
|
||||
(json!([0, 0, 0]), false, true),
|
||||
(json!({"direction": [0, 0, 0]}), false, true),
|
||||
(json!([0, 1]), false, false),
|
||||
(json!([0, 0, 1e9]), false, false),
|
||||
(json!({"direction": "x"}), false, false),
|
||||
(json!({}), false, false),
|
||||
(json!(5), false, false),
|
||||
(json!(true), false, false),
|
||||
] {
|
||||
let reader = crate::cmd::sweep_orientation(&v).is_ok();
|
||||
assert_eq!(accepts::<SweepOrientationParam>(&v), reader, "{v}");
|
||||
assert_eq!(reader, accepted, "{v}");
|
||||
assert_eq!(conforms(orientation.as_value(), orientation.as_value(), &v), schema, "schema {v}");
|
||||
}
|
||||
let fit = input_schema_for::<RailFitParam>();
|
||||
for (v, accepted, schema) in [
|
||||
(json!("scale"), true, true),
|
||||
(json!("stretch"), true, true),
|
||||
(json!("STRETCH"), true, false),
|
||||
(json!("stretch "), false, false),
|
||||
(json!("wobble"), false, false),
|
||||
(json!(1), false, false),
|
||||
] {
|
||||
let reader = crate::cmd::rail_fit_form(&v).is_ok();
|
||||
assert_eq!(accepts::<RailFitParam>(&v), reader, "{v}");
|
||||
assert_eq!(reader, accepted, "{v}");
|
||||
assert_eq!(conforms(fit.as_value(), fit.as_value(), &v), schema, "schema {v}");
|
||||
}
|
||||
for (v, accepted) in
|
||||
[(json!(10), true), (json!("90 deg"), true), (json!(" a / 2 "), true), (json!(" "), false), (json!(true), false), (json!(1e9), false)]
|
||||
{
|
||||
let want = crate::params::expr(&json!({ "v": v.clone() }), "v");
|
||||
assert_eq!(serde_json::from_value::<SweepTwist>(v.clone()).ok().map(|t| t.0), want, "{v}");
|
||||
assert_eq!(want.is_some(), accepted, "{v}");
|
||||
}
|
||||
let rails = input_schema_for::<SweepRails>();
|
||||
for (v, accepted) in [
|
||||
(json!([{"curves": ["a"]}]), true),
|
||||
(json!([{"curves": "a"}]), true),
|
||||
(json!([{"sketch": "R", "curves": ["a", "b"]}, {"sketch": 3, "curves": ["c"]}]), true),
|
||||
(json!([{"sketch": null, "curves": ["a"]}]), true),
|
||||
(json!([{"curves": vec!["a"; 1000]}]), true),
|
||||
(json!([{"curves": vec!["a"; 1001]}]), false),
|
||||
(json!([]), false),
|
||||
(json!([{"curves": ["a"]}, {"curves": ["b"]}, {"curves": ["c"]}]), false),
|
||||
(json!([{}]), false),
|
||||
(json!([{"curves": []}]), false),
|
||||
(json!([{"curves": [1]}]), false),
|
||||
(json!([{"sketch": true, "curves": ["a"]}]), false),
|
||||
(json!([{"curves": ["a"], "skecth": "R"}]), false),
|
||||
(json!(["a"]), false),
|
||||
(json!({"curves": ["a"]}), false),
|
||||
(json!("a"), false),
|
||||
] {
|
||||
assert_eq!(accepts::<SweepRails>(&v), accepted, "{v}");
|
||||
assert_eq!(conforms(rails.as_value(), rails.as_value(), &v), accepted, "schema {v}");
|
||||
}
|
||||
let issue = serde_json::from_value::<SweepRails>(json!([{"curves": ["a"]}, {"curves": ["b"], "skecth": "R"}])).err().map(|e| e.to_string());
|
||||
assert!(issue.is_some_and(|e| e.ends_with("unknown key `skecth` in `rails[1]`: a rail takes `sketch` and `curves`")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
|
||||
@@ -88,7 +88,7 @@ fn describe_returns_both_schemas() {
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.emboss"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
|
||||
@@ -999,6 +999,140 @@ impl JsonSchema for SketchRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, as `features::sweep_orientation` reads it, with its messages.
|
||||
/// `null` is the default, as the handler reads it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SweepOrientationParam(pub solidcraft_doc::SweepOrientation);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepOrientationParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::sweep_orientation(&Value::deserialize(d)?).map(SweepOrientationParam).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SweepOrientationParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SweepOrientation".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "How the profile turns as it follows the path: perpendicular (the default), square to the path and turning with it; parallel, as drawn, moving without turning (the path must keep crossing the profile's plane); or a direction, [x, y, z], {\"x\", \"y\", \"z\"} or {\"direction\": [x, y, z]}, not zero: square to the path, its turn about the path fixed so that the direction's projection onto the profile stays where it was at the start. Matching ignores case.",
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["perpendicular", "parallel"]},
|
||||
point,
|
||||
{"type": "object", "properties": {"direction": point}, "required": ["direction"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's `twist`: an expression as [`AngleExpr`] reads one, with the handler's message for
|
||||
/// anything else. Whether it evaluates, and to within four turns, is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepTwist(pub Expr);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepTwist {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::expr(&wrap(Value::deserialize(d)?), "v")
|
||||
.map(SweepTwist)
|
||||
.ok_or_else(|| D::Error::custom(whole("`twist` must be an angle or an expression")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Guide rails' `rail_fit`, as `features::rail_fit_form` reads it, with its message.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RailFitParam(pub solidcraft_doc::RailFit);
|
||||
|
||||
impl<'de> Deserialize<'de> for RailFitParam {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::rail_fit_form(&Value::deserialize(d)?).map(RailFitParam).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RailFitParam {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RailFit".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
enum_schema(
|
||||
&["scale", "stretch"],
|
||||
"How guide rails reshape the profile: scale (the default) grows and shrinks it evenly with the distance from the path to the rail (between the rails); stretch only across to the rail (between the rails). Matching ignores case.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `features::guide_rails` says about a sweep's or a loft's (`what`) `rails`, as given (not
|
||||
/// `null`), before it looks a sketch up: its message, or `None`. `default_sketch`: a rail may
|
||||
/// leave its `sketch` out. A key a rail doesn't take, which the handler ignores, is refused too.
|
||||
fn guide_rails_problem(v: &Value, what: &str, default_sketch: bool) -> Option<String> {
|
||||
let Value::Array(given) = v else { return Some(whole("`rails` must list one or two rails: [{sketch, curves: [ids]}]")) };
|
||||
if !(1..=2).contains(&given.len()) {
|
||||
return Some(whole(format!("a {what} takes one or two guide rails")));
|
||||
}
|
||||
for (i, rail) in given.iter().enumerate() {
|
||||
match rail.get("sketch").filter(|v| !v.is_null()) {
|
||||
None if !default_sketch => return Some(whole("each rail needs the `sketch` its curves are in")),
|
||||
Some(sketch) if crate::cmd::sketch_key(Some(sketch)).is_none() => return Some(whole("`rails[].sketch` must be a sketch id or name")),
|
||||
_ => {}
|
||||
}
|
||||
let curves = match params::string_list(params::UNKNOWN_COMMAND, rail, "curves") {
|
||||
Ok(curves) => curves,
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => return Some(whole(msg)),
|
||||
Err(e) => return Some(whole(e)),
|
||||
};
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Some(whole("each rail's `curves` must list 1…1000 curve ids in order"));
|
||||
}
|
||||
if let Some(key) = rail.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "sketch" | "curves"))) {
|
||||
let key = super::check::truncate(key, 64);
|
||||
return Some(whole(format!("unknown key `{key}` in `rails[{i}]`: a rail takes `sketch` and `curves`")));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails`, as `features::guide_rails` reads them before it looks the sketches up,
|
||||
/// with its messages: one or two `{sketch?, curves}`, each rail's sketch the path's when it
|
||||
/// names none. Kept as given, for the handler. Whether the sketches and curves exist, and that
|
||||
/// `orientation`, `twist` and `scale` are left out, is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepRails(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for SweepRails {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match guide_rails_problem(&v, "sweep", true) {
|
||||
Some(why) => Err(D::Error::custom(why)),
|
||||
None => Ok(SweepRails(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SweepRails {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SweepRails".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let sketch = generator.subschema_for::<SketchRef>();
|
||||
json_schema!({
|
||||
"description": "One or two guide rails, each {\"sketch\"?: the sketch its curves are in (default: the path's sketch), \"curves\": its curve ids in order, 1 to 1000 (one id may be given as text)}.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch": {"anyOf": [sketch, {"type": "null"}]},
|
||||
"curves": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}]}
|
||||
},
|
||||
"required": ["curves"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `features::plane_param` says about `v` as a plane before it looks anything up, as a
|
||||
/// complete message, or `None`. Names and ids are the handler's to look up.
|
||||
fn plane_problem(v: &Value) -> Option<String> {
|
||||
|
||||
@@ -43,7 +43,6 @@ solid.revolve
|
||||
solid.shell
|
||||
solid.split_body
|
||||
solid.split_face
|
||||
solid.sweep
|
||||
solid.thread
|
||||
surface.blend
|
||||
surface.extend
|
||||
@@ -52,5 +51,4 @@ surface.loft
|
||||
surface.patch
|
||||
surface.revolve
|
||||
surface.ruled
|
||||
surface.sweep
|
||||
surface.trim
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
|
||||
//! triangles joined by three links, left to the iteration by the construction) under random
|
||||
//! dimension edits from its previous solved state, with every failure that remains proven
|
||||
//! infeasible; and the solve without continuation when nothing it could help with changed.
|
||||
//! infeasible; an edit the continuation cannot reach, solved in steps; and the solve without
|
||||
//! continuation when nothing it could help with changed.
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
@@ -342,3 +343,25 @@ fn hostile_old_values_never_panic() {
|
||||
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_continuation_cannot_reach_is_solved_in_steps() {
|
||||
// Every distance of the linkage changed at once, by 0.6× to 2.3×. The one-jump solve fails
|
||||
// and so does the continuation from the layout (its branch ends); the edit made in steps
|
||||
// reaches the new values with both triangles turning as before.
|
||||
let new = [54.0, 59.0, 72.0, 31.0, 78.0, 76.0, 33.0, 61.0, 82.0];
|
||||
let mut rng = Lcg(1);
|
||||
let (mut sk, old) = linkage(&mut rng, 1.0, 1.0, false);
|
||||
set_distances(&mut sk, &new);
|
||||
let before = turns(&sk);
|
||||
let mut jump = sk.clone();
|
||||
assert!(!switch::without_differential(|| solve(&mut jump)).ok());
|
||||
let mut edit = sk.clone();
|
||||
let report = switch::without_differential(|| solve_after_value_edit(&mut edit, &old));
|
||||
assert!(report.keeps_orientation(), "{report:?}");
|
||||
assert_eq!(turns(&edit), before);
|
||||
for (&(a, b), d) in PAIRS.iter().zip(new) {
|
||||
let measured = edit.points[a + 1].pos.dist(edit.points[b + 1].pos);
|
||||
assert!((measured - d).abs() < 1e-6, "{a}-{b}: {measured} for {d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1839,7 +1839,84 @@ pub fn solve_after_edit(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[V
|
||||
/// geometry restoration are identical at every analysis level.
|
||||
pub fn solve_after_edit_with_analysis(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[Vec2]>, analysis: Analysis) -> SolveReport {
|
||||
let old_values: Vec<Option<f64>> = sk.constraints.iter().map(|c| old.get(&c.id).copied()).collect();
|
||||
solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full())
|
||||
let before = stored.is_none().then(|| sk.clone());
|
||||
let report = solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full());
|
||||
if report.keeps_orientation() {
|
||||
return report;
|
||||
}
|
||||
match before.and_then(|before| solve_in_steps(&before, old, analysis)) {
|
||||
Some((stepped, stepped_report)) => {
|
||||
*sk = stepped;
|
||||
stepped_report
|
||||
}
|
||||
None => report,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value edit solved in [`EDIT_STEPS`] steps (each a few times more of them than the last
|
||||
/// try), the whole sketch at a time, each step a full solve (seed and iteration) from the last
|
||||
/// step's solution. Continuation follows one solution branch by the iteration alone, from
|
||||
/// sub-step to sub-step; that branch can end before the new values where a solve from the
|
||||
/// values a step before still reaches them keeping every turn (the linkage family in
|
||||
/// `continuation_tests`). `None` when no try reaches the new values keeping every orientation,
|
||||
/// when nothing that can be stepped changed, or when the sketch is not solved for the old values.
|
||||
fn solve_in_steps(start: &Sketch, old: &DrivingValues, analysis: Analysis) -> Option<(Sketch, SolveReport)> {
|
||||
let changed: Vec<(usize, f64, f64)> = start
|
||||
.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let new = c.kind.value()?;
|
||||
let was = *old.get(&c.id)?;
|
||||
(was != new && was.is_finite() && new.is_finite()).then_some((i, was, new))
|
||||
})
|
||||
.collect();
|
||||
let steppable = |&(i, was, new): &(usize, f64, f64)| start.constraints.get(i).is_some_and(|c| steppable(&c.kind, was, new));
|
||||
if changed.is_empty() || !changed.iter().all(steppable) || !solved_for(start, old) {
|
||||
return None;
|
||||
}
|
||||
'tries: for steps in EDIT_STEPS {
|
||||
let mut at = start.clone();
|
||||
let mut values = old.clone();
|
||||
let mut last = None;
|
||||
for k in 1..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
for &(i, was, new) in &changed {
|
||||
if let Some(c) = at.constraints.get_mut(i) {
|
||||
c.kind.set_value(if k == steps { new } else { was + (new - was) * t });
|
||||
}
|
||||
}
|
||||
let from: Vec<Option<f64>> = at.constraints.iter().map(|c| values.get(&c.id).copied()).collect();
|
||||
let step_analysis = if k == steps { analysis } else { Analysis::StatusOnly };
|
||||
let report = solve_from(&mut at, &Hold::default(), None, step_analysis, Some(&from), &mut Budget::full());
|
||||
if !report.keeps_orientation() {
|
||||
continue 'tries;
|
||||
}
|
||||
values = at.driving_values();
|
||||
last = Some(report);
|
||||
}
|
||||
return last.map(|report| (at, report));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// How many steps [`solve_in_steps`] tries a value edit in, fewest first.
|
||||
const EDIT_STEPS: [usize; 4] = [2, 4, 8, 16];
|
||||
|
||||
/// The sketch's geometry is a solved state for the driving values `old`: there is a state to
|
||||
/// step from and orientations to keep, as `ValueEdit::starts_solved` decides it per component
|
||||
/// (a sketch stored unsolved has neither, and its one-jump result stands).
|
||||
fn solved_for(sk: &Sketch, old: &DrivingValues) -> bool {
|
||||
let mut was = sk.clone();
|
||||
for c in &mut was.constraints {
|
||||
if let (Some(_), Some(v)) = (c.kind.value(), old.get(&c.id)) {
|
||||
c.kind.set_value(*v);
|
||||
}
|
||||
}
|
||||
let lay = layout(&was, &Hold::default());
|
||||
let mut x = initial_x(&was, &lay);
|
||||
let (blocks, _) = build_blocks(&was, &lay, &points_on_curves(&was));
|
||||
max_abs(&system(&was, &lay, &blocks, &mut x, false).0) < ACCEPT
|
||||
}
|
||||
|
||||
/// The orientations of `stored` that `solved` (the same sketch, its links moved and solved
|
||||
|
||||
@@ -81,6 +81,7 @@ pub const SKIPPED_FUNCTIONS: &[(&str, &str)] = &[
|
||||
("crates/doc/src/eval.rs:feature_json", "fingerprints feature inputs; triangles are only embedded mesh-body source data"),
|
||||
("crates/doc/src/eval.rs:mesh_bodies", "mesh bodies: a mesh import's triangles"),
|
||||
("crates/doc/src/eval.rs:mesh_content_digest", "mesh-body imports: hashes source arrays without geometric classification"),
|
||||
("crates/doc/src/project3d.rs:flat_outline", "mesh bodies: the flat patch of an imported mesh's triangles that a face pick on it means"),
|
||||
("crates/engine/src/cmd/file.rs:saved_preview", "the preview picture saved in a design"),
|
||||
("crates/kernel/src/body.rs:Body::classify_point", "mesh bodies: its mesh-body branch (a B-rep is classified by SMLib)"),
|
||||
("crates/kernel/src/body.rs:Body::mesh_untimed", "the mesher: a body's tessellation"),
|
||||
|
||||
Reference in New Issue
Block a user