@@ -8,6 +8,7 @@ import { VerticalLine } from "./constraints/vertical_line.js";
88import { HorizontalDistanceBetweenPoints } from "./constraints/horizontal_distance.js" ;
99import { VerticalDistanceBetweenPoints } from "./constraints/vertical_distance.js" ;
1010import { GradientBasedSolver } from "./gradient_based_solver.js" ;
11+ import { serializeSketchToString , deserializeSketchFromString } from "./sketch_serializer.js" ;
1112
1213/** The tools available in the toolbar. */
1314export type Tool = "none" | "point" | "line" ;
@@ -149,6 +150,25 @@ export class Viewport {
149150 document . getElementById ( "export-dxf" ) ?. addEventListener ( "click" , ( ) => {
150151 this . exportDxf ( ) ;
151152 } ) ;
153+
154+ // Save sketch button
155+ document . getElementById ( "save-sketch" ) ?. addEventListener ( "click" , ( ) => {
156+ this . saveSketch ( ) ;
157+ } ) ;
158+
159+ // Open sketch button + hidden file input
160+ const fileInput = document . getElementById ( "open-file-input" ) as HTMLInputElement | null ;
161+ document . getElementById ( "open-sketch" ) ?. addEventListener ( "click" , ( ) => {
162+ fileInput ?. click ( ) ;
163+ } ) ;
164+ fileInput ?. addEventListener ( "change" , ( ) => {
165+ const file = fileInput . files ?. [ 0 ] ;
166+ if ( ! file ) return ;
167+ file . text ( ) . then ( ( text ) => {
168+ this . openSketch ( text ) ;
169+ fileInput . value = "" ; // allow re-opening the same file
170+ } ) ;
171+ } ) ;
152172 }
153173
154174 /** Adjust the viewport so all Point2 primitives are visible, with some padding. */
@@ -689,21 +709,51 @@ export class Viewport {
689709 info . style . marginBottom = "4px" ;
690710 panel . appendChild ( info ) ;
691711
712+ const dx = line . end . x - line . start . x ;
713+ const dy = line . end . y - line . start . y ;
714+ const length = Math . sqrt ( dx * dx + dy * dy ) ;
715+ const lengthDiv = document . createElement ( "div" ) ;
716+ lengthDiv . textContent = `Length: ${ this . formatValue ( length ) } ` ;
717+ lengthDiv . style . marginBottom = "4px" ;
718+ panel . appendChild ( lengthDiv ) ;
719+
692720 // Check for existing HorizontalLine / VerticalLine constraint
693721 const constraints = this . sketch . getConstraintsOnPrimitive ( line ) ;
694722 const existingH = constraints . find ( ( c ) : c is HorizontalLine => c instanceof HorizontalLine ) ?? null ;
695723 const existingV = constraints . find ( ( c ) : c is VerticalLine => c instanceof VerticalLine ) ?? null ;
696724 const existingDirectional = existingH ?? existingV ;
697725
698- if ( constraints . length > 0 ) {
726+ // Find distance constraints whose two points match the line's start/end (in either order)
727+ const matchesLineEndpoints = ( c : HorizontalDistanceBetweenPoints | VerticalDistanceBetweenPoints ) : boolean => {
728+ const refs = c . getReferencedPrimitives ( ) ;
729+ const [ cp1 , cp2 ] = refs ;
730+ return ( cp1 === line . start && cp2 === line . end ) || ( cp1 === line . end && cp2 === line . start ) ;
731+ } ;
732+
733+ const allConstraints = this . sketch . getConstraints ( ) ;
734+ const existingHDist = allConstraints . find (
735+ ( c ) : c is HorizontalDistanceBetweenPoints =>
736+ c instanceof HorizontalDistanceBetweenPoints && matchesLineEndpoints ( c )
737+ ) ?? null ;
738+ const existingVDist = allConstraints . find (
739+ ( c ) : c is VerticalDistanceBetweenPoints =>
740+ c instanceof VerticalDistanceBetweenPoints && matchesLineEndpoints ( c )
741+ ) ?? null ;
742+
743+ // Combine line-level and distance constraints for display
744+ const allDisplayed : import ( "./interfaces.js" ) . ConstraintLike [ ] = [ ...constraints ] ;
745+ if ( existingHDist ) allDisplayed . push ( existingHDist ) ;
746+ if ( existingVDist ) allDisplayed . push ( existingVDist ) ;
747+
748+ if ( allDisplayed . length > 0 ) {
699749 const section = document . createElement ( "div" ) ;
700750 section . className = "prop-constraints" ;
701751 const heading = document . createElement ( "div" ) ;
702752 heading . className = "prop-subtitle" ;
703- heading . textContent = `Constraints (${ constraints . length } )` ;
753+ heading . textContent = `Constraints (${ allDisplayed . length } )` ;
704754 section . appendChild ( heading ) ;
705755 const list = document . createElement ( "ul" ) ;
706- for ( const c of constraints ) {
756+ for ( const c of allDisplayed ) {
707757 const li = document . createElement ( "li" ) ;
708758 li . textContent = c . description ;
709759 list . appendChild ( li ) ;
@@ -712,48 +762,91 @@ export class Viewport {
712762 panel . appendChild ( section ) ;
713763 }
714764
715- if ( existingDirectional ) {
716- // Allow deleting the existing constraint
765+ // Delete buttons for existing constraints
766+ const deletable = [ existingDirectional , existingHDist , existingVDist ] . filter ( Boolean ) as import ( "./interfaces.js" ) . ConstraintLike [ ] ;
767+ if ( deletable . length > 0 ) {
717768 const section = document . createElement ( "div" ) ;
718769 section . className = "prop-add-constraint" ;
719- const deleteBtn = document . createElement ( "button" ) ;
720- deleteBtn . textContent = `Delete ${ existingDirectional . description } ` ;
721- deleteBtn . addEventListener ( "click" , ( ) => {
722- this . sketch . removeConstraint ( existingDirectional ) ;
723- this . isSolved = false ;
724- this . updatePropertiesPanel ( ) ;
725- this . draw ( ) ;
726- } ) ;
727- section . appendChild ( deleteBtn ) ;
770+ for ( const c of deletable ) {
771+ const deleteBtn = document . createElement ( "button" ) ;
772+ deleteBtn . textContent = `Delete ${ c . description } ` ;
773+ deleteBtn . addEventListener ( "click" , ( ) => {
774+ this . sketch . removeConstraint ( c ) ;
775+ this . isSolved = false ;
776+ this . updatePropertiesPanel ( ) ;
777+ this . draw ( ) ;
778+ } ) ;
779+ section . appendChild ( deleteBtn ) ;
780+ }
728781 panel . appendChild ( section ) ;
729- } else {
730- // Allow adding HorizontalLine or VerticalLine
782+ }
783+
784+ // Add constraint buttons
785+ const canAddH = ! existingDirectional ;
786+ const canAddV = ! existingDirectional ;
787+ const canAddHDist = ! existingHDist ;
788+ const canAddVDist = ! existingVDist ;
789+
790+ if ( canAddH || canAddV || canAddHDist || canAddVDist ) {
731791 const section = document . createElement ( "div" ) ;
732792 section . className = "prop-add-constraint" ;
733793 const heading = document . createElement ( "div" ) ;
734794 heading . className = "prop-subtitle" ;
735795 heading . textContent = "Add Constraint" ;
736796 section . appendChild ( heading ) ;
737797
738- const addHBtn = document . createElement ( "button" ) ;
739- addHBtn . textContent = "Add HorizontalLine" ;
740- addHBtn . addEventListener ( "click" , ( ) => {
741- this . sketch . addConstraint ( new HorizontalLine ( line ) ) ;
742- this . isSolved = false ;
743- this . updatePropertiesPanel ( ) ;
744- this . draw ( ) ;
745- } ) ;
746- section . appendChild ( addHBtn ) ;
798+ if ( canAddH ) {
799+ const addHBtn = document . createElement ( "button" ) ;
800+ addHBtn . textContent = "Add HorizontalLine" ;
801+ addHBtn . addEventListener ( "click" , ( ) => {
802+ this . sketch . addConstraint ( new HorizontalLine ( line ) ) ;
803+ this . isSolved = false ;
804+ this . updatePropertiesPanel ( ) ;
805+ this . draw ( ) ;
806+ } ) ;
807+ section . appendChild ( addHBtn ) ;
808+ }
809+
810+ if ( canAddV ) {
811+ const addVBtn = document . createElement ( "button" ) ;
812+ addVBtn . textContent = "Add VerticalLine" ;
813+ addVBtn . addEventListener ( "click" , ( ) => {
814+ this . sketch . addConstraint ( new VerticalLine ( line ) ) ;
815+ this . isSolved = false ;
816+ this . updatePropertiesPanel ( ) ;
817+ this . draw ( ) ;
818+ } ) ;
819+ section . appendChild ( addVBtn ) ;
820+ }
821+
822+ if ( canAddHDist ) {
823+ const addHDistBtn = document . createElement ( "button" ) ;
824+ addHDistBtn . textContent = "Add Horizontal Distance" ;
825+ addHDistBtn . addEventListener ( "click" , ( ) => {
826+ const dist = parseFloat ( prompt ( "Horizontal distance:" , "1" ) ?? "" ) ;
827+ if ( isNaN ( dist ) ) return ;
828+ this . sketch . addConstraint ( new HorizontalDistanceBetweenPoints ( line . start , line . end , dist ) ) ;
829+ this . isSolved = false ;
830+ this . updatePropertiesPanel ( ) ;
831+ this . draw ( ) ;
832+ } ) ;
833+ section . appendChild ( addHDistBtn ) ;
834+ }
835+
836+ if ( canAddVDist ) {
837+ const addVDistBtn = document . createElement ( "button" ) ;
838+ addVDistBtn . textContent = "Add Vertical Distance" ;
839+ addVDistBtn . addEventListener ( "click" , ( ) => {
840+ const dist = parseFloat ( prompt ( "Vertical distance:" , "1" ) ?? "" ) ;
841+ if ( isNaN ( dist ) ) return ;
842+ this . sketch . addConstraint ( new VerticalDistanceBetweenPoints ( line . start , line . end , dist ) ) ;
843+ this . isSolved = false ;
844+ this . updatePropertiesPanel ( ) ;
845+ this . draw ( ) ;
846+ } ) ;
847+ section . appendChild ( addVDistBtn ) ;
848+ }
747849
748- const addVBtn = document . createElement ( "button" ) ;
749- addVBtn . textContent = "Add VerticalLine" ;
750- addVBtn . addEventListener ( "click" , ( ) => {
751- this . sketch . addConstraint ( new VerticalLine ( line ) ) ;
752- this . isSolved = false ;
753- this . updatePropertiesPanel ( ) ;
754- this . draw ( ) ;
755- } ) ;
756- section . appendChild ( addVBtn ) ;
757850 panel . appendChild ( section ) ;
758851 }
759852
@@ -979,6 +1072,34 @@ export class Viewport {
9791072 URL . revokeObjectURL ( url ) ;
9801073 }
9811074
1075+ /** Serialize the current sketch to JSON and trigger a file download. */
1076+ private saveSketch ( ) : void {
1077+ const json = serializeSketchToString ( this . sketch ) ;
1078+ const blob = new Blob ( [ json ] , { type : "application/json" } ) ;
1079+ const url = URL . createObjectURL ( blob ) ;
1080+ const a = document . createElement ( "a" ) ;
1081+ a . href = url ;
1082+ a . download = "sketch.json" ;
1083+ a . click ( ) ;
1084+ URL . revokeObjectURL ( url ) ;
1085+ }
1086+
1087+ /** Load a sketch from a JSON string, replacing the current sketch. */
1088+ private openSketch ( text : string ) : void {
1089+ try {
1090+ const restored = deserializeSketchFromString ( text ) ;
1091+ this . sketch = restored ;
1092+ this . pendingLineStart = null ;
1093+ this . pendingConstraint = null ;
1094+ this . isSolved = false ;
1095+ this . selectPrimitive ( null ) ;
1096+ this . zoomToFit ( ) ;
1097+ this . showStatus ( "Sketch loaded" ) ;
1098+ } catch ( e : any ) {
1099+ this . showStatus ( `Failed to open: ${ e . message ?? e } ` ) ;
1100+ }
1101+ }
1102+
9821103 /** Draw a persistent solved/unsolved indicator in the top-right corner. */
9831104 private drawSolvedIndicator ( ctx : CanvasRenderingContext2D ) : void {
9841105 const dpr = window . devicePixelRatio || 1 ;
0 commit comments